79a85cf707
Add a safe blurred-overlay flow for modal dialogs by implementing run_dialog_with_blur and _apply_blur_to_pixmap on YTSageApp. The new flow captures a window screenshot, generates a blurred/dimmed pixmap via a QGraphicsScene + QGraphicsBlurEffect, shows it in an overlay QLabel with a fade-in, runs the dialog, and then removes the overlay. Replace many direct dialog.exec() calls with run_dialog_with_blur(...) across the main GUI (download settings, about, history, playlist selection, custom options, cookie login, ffmpeg check, time range, setup success dialogs, etc.). Also add imports required for pixmap/graphics handling. Make animate_widget_fade_in usage safer by checking for the method before calling it and falling back to setVisible(True) for format table and thumbnails to avoid QPainter/graphics-effect conflicts. Small comment/cleanup in fade-in/out methods.
1704 lines
77 KiB
Python
1704 lines
77 KiB
Python
import json
|
|
import subprocess
|
|
import threading
|
|
import webbrowser
|
|
from pathlib import Path
|
|
|
|
import markdown
|
|
import requests
|
|
from packaging import version
|
|
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint
|
|
from PySide6.QtGui import QIcon
|
|
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QButtonGroup,
|
|
QCheckBox,
|
|
QDialog,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QProgressBar,
|
|
QPushButton,
|
|
QStyle,
|
|
QTextEdit,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
QGraphicsOpacityEffect,
|
|
QGraphicsBlurEffect,
|
|
QGraphicsScene,
|
|
QGraphicsPixmapItem,
|
|
)
|
|
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor
|
|
|
|
from .. import __version__ as APP_VERSION
|
|
from ..core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
|
|
from ..core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update, validate_video_url
|
|
from ..core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
|
|
from ..core.ytsage_deno import get_deno_path, setup_deno # Import the new Deno functions
|
|
from .ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
|
|
AboutDialog,
|
|
AutoUpdateThread,
|
|
CustomOptionsDialog,
|
|
DownloadSettingsDialog,
|
|
FFmpegCheckDialog,
|
|
HistoryDialog,
|
|
PlaylistSelectionDialog,
|
|
TimeRangeDialog,
|
|
YTDLPUpdateDialog,
|
|
)
|
|
from .ytsage_gui_format_table import FormatTableMixin
|
|
from .ytsage_gui_video_info import VideoInfoMixin
|
|
from .ytsage_gui_analysis import AnalysisMixin
|
|
from ..utils.ytsage_constants import (
|
|
ICON_PATH,
|
|
SOUND_PATH,
|
|
SUBPROCESS_CREATIONFLAGS,
|
|
VIDEO_EXTENSIONS,
|
|
AUDIO_EXTENSIONS,
|
|
SUBTITLE_EXTENSIONS,
|
|
)
|
|
from ..utils.ytsage_logger import logger
|
|
from ..utils.ytsage_config_manager import ConfigManager
|
|
from ..utils.ytsage_localization import LocalizationManager, _
|
|
from ..utils.ytsage_history_manager import HistoryManager
|
|
from .ytsage_stylesheet import StyleSheet
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
|
|
class UpdateCheckThread(QThread):
|
|
"""Background thread for checking application updates with parallel network requests."""
|
|
|
|
update_available = Signal(str, str, str) # version, url, changelog
|
|
|
|
# Reduced timeouts for faster failure detection
|
|
PYPI_TIMEOUT = 8
|
|
GITHUB_TIMEOUT = 5
|
|
|
|
def __init__(self, current_version):
|
|
super().__init__()
|
|
self.current_version = current_version
|
|
|
|
def _fetch_pypi_version(self) -> tuple[str | None, str | None]:
|
|
"""Fetch latest version from PyPI. Returns (version, error)."""
|
|
try:
|
|
response = requests.get(
|
|
"https://pypi.org/pypi/ytsage/json",
|
|
timeout=self.PYPI_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
pypi_data = response.json()
|
|
return pypi_data["info"]["version"], None
|
|
except requests.Timeout:
|
|
return None, "PyPI request timed out"
|
|
except requests.RequestException as e:
|
|
return None, f"PyPI request failed: {e}"
|
|
except Exception as e:
|
|
return None, f"Error parsing PyPI response: {e}"
|
|
|
|
def _fetch_github_changelog(self) -> str:
|
|
"""Fetch changelog from GitHub. Returns changelog text or fallback message."""
|
|
fallback = "View the full changelog on the [GitHub Releases](https://github.com/oop7/YTSage/releases) page."
|
|
try:
|
|
response = requests.get(
|
|
"https://api.github.com/repos/oop7/YTSage/releases/latest",
|
|
headers={"Accept": "application/vnd.github.v3+json"},
|
|
timeout=self.GITHUB_TIMEOUT,
|
|
)
|
|
if response.status_code == 200:
|
|
gh_data = response.json()
|
|
return gh_data.get("body", fallback) or fallback
|
|
return fallback
|
|
except Exception:
|
|
# Silently fallback if GitHub API fails (rate limiting, network issues, etc.)
|
|
return fallback
|
|
|
|
def run(self):
|
|
"""Check for updates using parallel network requests for better performance."""
|
|
try:
|
|
# Use ThreadPoolExecutor to make both requests in parallel
|
|
# This reduces total wait time from potentially 15s to ~8s max
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
# Submit both tasks
|
|
pypi_future = executor.submit(self._fetch_pypi_version)
|
|
github_future = executor.submit(self._fetch_github_changelog)
|
|
|
|
# Get PyPI result (this is required)
|
|
latest_version, error = pypi_future.result()
|
|
|
|
if error:
|
|
logger.debug(f"Update check failed: {error}")
|
|
return
|
|
|
|
if not latest_version:
|
|
logger.debug("No version returned from PyPI")
|
|
return
|
|
|
|
# Compare versions
|
|
if version.parse(latest_version) > version.parse(self.current_version):
|
|
release_url = "https://github.com/oop7/YTSage/releases/latest"
|
|
|
|
# Get GitHub changelog (may already be complete due to parallel execution)
|
|
changelog = github_future.result()
|
|
|
|
self.update_available.emit(latest_version, release_url, changelog)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Failed to check for updates: {e}")
|
|
|
|
|
|
class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): # Inherit from mixins
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
|
|
# Initialize localization system
|
|
saved_language = ConfigManager.get("language") or "en"
|
|
LocalizationManager.initialize(saved_language)
|
|
|
|
self.version = APP_VERSION
|
|
load_saved_path(self)
|
|
# Load custom icon
|
|
if ICON_PATH.exists():
|
|
self.setWindowIcon(QIcon(str(ICON_PATH)))
|
|
else:
|
|
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
|
|
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
|
|
self.signals = SignalManager()
|
|
self.download_paused = False
|
|
self.current_download = None
|
|
self.download_cancelled = False
|
|
self.is_updating_ytdlp = False # Initialize update flag
|
|
self.is_analyzing = False # Initialize analysis flag
|
|
self.save_thumbnail = False # Initialize thumbnail state
|
|
self.thumbnail_url = None # Add this to store thumbnail URL
|
|
self.all_formats = [] # Initialize all_formats
|
|
self.available_subtitles = {}
|
|
self.available_automatic_subtitles = {}
|
|
self.is_playlist = False
|
|
self.playlist_info = None
|
|
self.video_info = None
|
|
self.playlist_entries = [] # Initialize playlist entries
|
|
self.selected_playlist_items = None # Initialize selection string
|
|
self.save_description = False # Initialize description state
|
|
self.embed_chapters = False # Initialize chapters state
|
|
self.subtitle_filter = ""
|
|
self.thumbnail_image = None
|
|
self.video_url = ""
|
|
self.selected_subtitles = [] # Initialize selected subtitles list
|
|
# Initialize cookie settings from saved config
|
|
self._initialize_cookie_settings_from_config()
|
|
# Initialize proxy settings from config
|
|
self.proxy_url = ConfigManager.get("proxy_url")
|
|
self.geo_proxy_url = ConfigManager.get("geo_proxy_url")
|
|
self.speed_limit_value = None # Store speed limit value
|
|
self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s)
|
|
self.download_section = None
|
|
self.force_keyframes = False
|
|
# Initialize output format settings
|
|
self.force_output_format = ConfigManager.get("force_output_format") or False
|
|
self.preferred_output_format = ConfigManager.get("preferred_output_format") or "mp4"
|
|
self.force_audio_format = ConfigManager.get("force_audio_format") or False
|
|
self.preferred_audio_format = ConfigManager.get("preferred_audio_format") or "best"
|
|
# Track if video analysis is completed
|
|
self.analysis_completed = False
|
|
|
|
# Initialize audio player
|
|
self.player = QMediaPlayer()
|
|
self.audio_output = QAudioOutput()
|
|
self.player.setAudioOutput(self.audio_output)
|
|
|
|
self.init_ui()
|
|
|
|
# Defer heavy start-up tasks to ensure UI renders immediately
|
|
QTimer.singleShot(100, self._perform_startup_checks)
|
|
|
|
self.setStyleSheet(StyleSheet.MAIN)
|
|
self.signals.update_progress.connect(self.update_progress_bar)
|
|
|
|
# After adding format buttons
|
|
self.video_button.clicked.connect(self.filter_formats) # Connect video button
|
|
self.audio_button.clicked.connect(self.filter_formats) # Connect audio button
|
|
|
|
# Add connections to handle video/audio mode-specific controls
|
|
self.video_button.clicked.connect(self.handle_mode_change)
|
|
self.audio_button.clicked.connect(self.handle_mode_change)
|
|
|
|
# Initialize UI state based on current mode
|
|
self.handle_mode_change()
|
|
|
|
|
|
def _perform_startup_checks(self):
|
|
"""Perform potentially blocking startup checks after UI is shown."""
|
|
|
|
# Check for FFmpeg before proceeding
|
|
if not check_ffmpeg():
|
|
self.show_ffmpeg_dialog()
|
|
|
|
# Check for yt-dlp in our app's bin directory or system PATH
|
|
ytdlp_path = get_yt_dlp_path()
|
|
if ytdlp_path == "yt-dlp": # Not found in app dir or PATH
|
|
self.show_ytdlp_setup_dialog()
|
|
else:
|
|
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
|
|
|
# Check for Deno in our app's bin directory
|
|
deno_path = get_deno_path()
|
|
if deno_path == "deno": # Not found in app dir
|
|
self.show_deno_setup_dialog()
|
|
else:
|
|
logger.info(f"Using Deno from: {deno_path}")
|
|
|
|
self.check_for_updates()
|
|
|
|
# Check for auto-updates if enabled
|
|
QTimer.singleShot(2000, self.check_auto_update_ytdlp) # Further delay auto-update check
|
|
|
|
def play_notification_sound(self) -> None:
|
|
"""Play notification sound asynchronously (non-blocking)."""
|
|
try:
|
|
# Check if the notification sound file exists
|
|
if not SOUND_PATH.exists():
|
|
logger.warning(f"Notification sound file not found at: {SOUND_PATH}")
|
|
return
|
|
|
|
# Play the sound using QtMultimedia
|
|
self.player.setSource(QUrl.fromLocalFile(str(SOUND_PATH)))
|
|
self.player.play()
|
|
logger.debug("Notification sound played")
|
|
except Exception as e:
|
|
logger.exception(f"Error playing notification sound: {e}")
|
|
|
|
def _initialize_cookie_settings_from_config(self) -> None:
|
|
"""Initialize cookie settings - cookies are NOT auto-activated on startup.
|
|
User must explicitly click Apply in the dialog each session."""
|
|
# Cookies always start inactive on app launch
|
|
# User must click Apply in Custom Options dialog to activate them
|
|
self.cookie_file_path = None
|
|
self.browser_cookies_option = None
|
|
logger.debug("Cookie settings initialized - no cookies active (user must apply manually)")
|
|
|
|
def init_ui(self) -> None:
|
|
self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}")
|
|
self.setMinimumSize(900, 750)
|
|
|
|
# Main widget and layout
|
|
main_widget = QWidget()
|
|
self.setCentralWidget(main_widget)
|
|
layout = QVBoxLayout(main_widget)
|
|
layout.setSpacing(8)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
|
|
# URL input section
|
|
url_layout = QHBoxLayout()
|
|
url_layout.setSpacing(10)
|
|
|
|
self.url_input = QLineEdit()
|
|
self.url_input.setPlaceholderText(_("main_ui.url_placeholder"))
|
|
self.url_input.returnPressed.connect(self.analyze_url) # Analyze on Enter key
|
|
self.url_input.textChanged.connect(self._on_url_text_changed) # Enable/disable analyze button
|
|
self.url_input.setMinimumHeight(42)
|
|
|
|
# Paste URL button
|
|
self.paste_button = QPushButton(_("buttons.paste_url"))
|
|
self.paste_button.clicked.connect(self.paste_url)
|
|
self.paste_button.setMinimumHeight(42)
|
|
self.paste_button.setMinimumWidth(115)
|
|
self.paste_button.setStyleSheet(StyleSheet.PASTE_BUTTON)
|
|
|
|
# Analyze button with app's red theme
|
|
self.analyze_button = QPushButton(_("buttons.analyze"))
|
|
self.analyze_button.clicked.connect(self.analyze_url)
|
|
self.analyze_button.setEnabled(False) # Disabled until URL is entered
|
|
self.analyze_button.setMinimumHeight(42)
|
|
self.analyze_button.setMinimumWidth(115)
|
|
self.analyze_button.setStyleSheet(StyleSheet.ANALYZE_BUTTON)
|
|
|
|
url_layout.addWidget(self.url_input, 1)
|
|
url_layout.addWidget(self.paste_button)
|
|
url_layout.addWidget(self.analyze_button)
|
|
|
|
layout.addLayout(url_layout)
|
|
|
|
# Video info container
|
|
video_info_container = QWidget()
|
|
video_info_layout = QVBoxLayout(video_info_container)
|
|
video_info_layout.setSpacing(5)
|
|
video_info_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Add media info layout (Thumbnail | Video Details)
|
|
media_info_layout = self.setup_video_info_section()
|
|
video_info_layout.addLayout(media_info_layout)
|
|
|
|
# Add video info container to main layout
|
|
layout.addWidget(video_info_container)
|
|
|
|
# --- Add Playlist Info Section Directly to Main Layout ---
|
|
# Add playlist info label (initially hidden)
|
|
self.playlist_info_label = self.setup_playlist_info_section()
|
|
layout.addWidget(self.playlist_info_label)
|
|
|
|
# Add playlist selection BUTTON (initially hidden) - REPLACED QLineEdit
|
|
self.playlist_select_btn = QPushButton(_("buttons.select_videos"))
|
|
self.playlist_select_btn.clicked.connect(self.open_playlist_selection_dialog)
|
|
self.playlist_select_btn.setVisible(False)
|
|
self.playlist_select_btn.setStyleSheet(StyleSheet.PLAYLIST_BUTTON)
|
|
layout.addWidget(self.playlist_select_btn)
|
|
# --- End Playlist Info Section ---
|
|
|
|
# Format controls section with minimal spacing
|
|
layout.addSpacing(5)
|
|
|
|
# Format selection layout (horizontal)
|
|
self.format_layout = QHBoxLayout()
|
|
|
|
# Show formats label
|
|
self.show_formats_label = QLabel(_("formats.show_formats"))
|
|
self.show_formats_label.setStyleSheet("color: white;")
|
|
self.format_layout.addWidget(self.show_formats_label)
|
|
|
|
# Format buttons group
|
|
self.format_buttons = QButtonGroup(self)
|
|
self.format_buttons.setExclusive(True)
|
|
|
|
# Video button
|
|
self.video_button = QPushButton(_("buttons.video"))
|
|
self.video_button.setCheckable(True)
|
|
self.video_button.setChecked(True) # Set video as default
|
|
self.video_button.setStyleSheet(StyleSheet.FORMAT_TOGGLE_BUTTON)
|
|
self.format_buttons.addButton(self.video_button)
|
|
self.format_layout.addWidget(self.video_button)
|
|
|
|
# Audio button
|
|
self.audio_button = QPushButton(_("buttons.audio_only"))
|
|
self.audio_button.setCheckable(True)
|
|
self.audio_button.setStyleSheet(StyleSheet.FORMAT_TOGGLE_BUTTON)
|
|
self.format_buttons.addButton(self.audio_button)
|
|
self.format_layout.addWidget(self.audio_button)
|
|
|
|
# Add Merge Subtitles checkbox (Moved here)
|
|
self.merge_subs_checkbox = QCheckBox(_("main_ui.merge_subtitles"))
|
|
self.merge_subs_checkbox.setStyleSheet(StyleSheet.CHECKBOX)
|
|
# Initially disable it, will be enabled if subtitles are selected later
|
|
self.merge_subs_checkbox.setEnabled(False)
|
|
self.format_layout.addWidget(self.merge_subs_checkbox)
|
|
|
|
# Add Save Thumbnail Checkbox (Moved here)
|
|
self.save_thumbnail_checkbox = QCheckBox(_("main_ui.save_thumbnail"))
|
|
self.save_thumbnail_checkbox.setChecked(False)
|
|
self.save_thumbnail_checkbox.stateChanged.connect(self.toggle_save_thumbnail)
|
|
self.save_thumbnail_checkbox.setStyleSheet(StyleSheet.CHECKBOX)
|
|
self.format_layout.addWidget(self.save_thumbnail_checkbox)
|
|
|
|
# Add Save Description Checkbox (Moved here)
|
|
self.save_description_checkbox = QCheckBox(_("main_ui.save_description"))
|
|
self.save_description_checkbox.setChecked(False)
|
|
self.save_description_checkbox.stateChanged.connect(self.toggle_save_description)
|
|
self.save_description_checkbox.setStyleSheet(StyleSheet.CHECKBOX)
|
|
self.format_layout.addWidget(self.save_description_checkbox)
|
|
|
|
# Add Embed Chapters Checkbox
|
|
self.embed_chapters_checkbox = QCheckBox(_("main_ui.embed_chapters"))
|
|
self.embed_chapters_checkbox.setChecked(False)
|
|
self.embed_chapters_checkbox.stateChanged.connect(self.toggle_embed_chapters)
|
|
self.embed_chapters_checkbox.setStyleSheet(StyleSheet.CHECKBOX)
|
|
self.format_layout.addWidget(self.embed_chapters_checkbox)
|
|
|
|
self.format_layout.addStretch()
|
|
|
|
layout.addLayout(self.format_layout)
|
|
|
|
# Format table with stretch
|
|
format_table = self.setup_format_table()
|
|
layout.addWidget(format_table, stretch=1)
|
|
|
|
# Download section
|
|
download_layout = QHBoxLayout()
|
|
|
|
# Replace the two separate buttons with a single Custom Options button
|
|
self.custom_options_btn = QPushButton(_("buttons.custom_options"))
|
|
self.custom_options_btn.clicked.connect(self.show_custom_options)
|
|
|
|
self.about_btn = QPushButton(_("buttons.about"))
|
|
self.about_btn.clicked.connect(self.show_about_dialog)
|
|
|
|
self.history_btn = QPushButton(_("buttons.history"))
|
|
self.history_btn.clicked.connect(self.show_history_dialog)
|
|
|
|
# Add new Time Range button
|
|
self.time_range_btn = QPushButton(_("buttons.trim_video"))
|
|
self.time_range_btn.clicked.connect(self.show_time_range_dialog)
|
|
|
|
# --- Rename Path Button to Settings Button ---
|
|
self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button
|
|
self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
|
|
self.settings_button.setToolTip(
|
|
_(
|
|
"main_ui.settings_tooltip",
|
|
path=self.last_path,
|
|
speed_limit=_("main_ui.speed_limit_none"),
|
|
)
|
|
) # Update initial tooltip
|
|
# --- End Settings Button ---
|
|
|
|
self.download_btn = QPushButton(_("buttons.download"))
|
|
self.download_btn.clicked.connect(self.start_download)
|
|
|
|
# Add pause and cancel buttons
|
|
self.pause_btn = QPushButton(_("buttons.pause"))
|
|
self.pause_btn.clicked.connect(self.toggle_pause)
|
|
self.pause_btn.setVisible(False) # Hidden initially
|
|
|
|
self.cancel_btn = QPushButton(_("buttons.cancel"))
|
|
self.cancel_btn.clicked.connect(self.cancel_download)
|
|
self.cancel_btn.setVisible(False) # Hidden initially
|
|
|
|
# Add all buttons to layout in the correct order
|
|
download_layout.addWidget(self.custom_options_btn)
|
|
download_layout.addWidget(self.about_btn)
|
|
download_layout.addWidget(self.history_btn)
|
|
download_layout.addWidget(self.time_range_btn) # New button position
|
|
download_layout.addWidget(self.settings_button)
|
|
download_layout.addWidget(self.download_btn)
|
|
download_layout.addWidget(self.pause_btn)
|
|
download_layout.addWidget(self.cancel_btn)
|
|
|
|
layout.addLayout(download_layout)
|
|
|
|
# Progress section with improved styling
|
|
progress_layout = QVBoxLayout()
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setRange(0, 10000) # Use 0-10000 range for 0.01% precision
|
|
self.progress_bar.setFormat("%p%") # Display as percentage
|
|
self.progress_bar.setStyleSheet(StyleSheet.PROGRESS_BAR)
|
|
progress_layout.addWidget(self.progress_bar)
|
|
|
|
# Setup smooth animation for progress bar
|
|
self._progress_animation = QPropertyAnimation(self.progress_bar, b"value")
|
|
self._progress_animation.setDuration(150) # 150ms smooth transition
|
|
self._progress_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
|
|
# Add download details label with improved styling
|
|
self.download_details_label = QLabel()
|
|
self.download_details_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.download_details_label.setStyleSheet(StyleSheet.STATUS_LABEL)
|
|
progress_layout.addWidget(self.download_details_label)
|
|
|
|
# Create a horizontal layout for status label and open folder button
|
|
status_layout = QHBoxLayout()
|
|
|
|
self.status_label = QLabel(_("app.ready"))
|
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.status_label.setStyleSheet(StyleSheet.STATUS_LABEL)
|
|
status_layout.addWidget(self.status_label)
|
|
|
|
# Add "Open Folder" button (initially hidden)
|
|
self.open_folder_btn = QPushButton("📁")
|
|
self.open_folder_btn.setToolTip(_("buttons.open_folder"))
|
|
self.open_folder_btn.setFixedSize(30, 30)
|
|
self.open_folder_btn.setStyleSheet(StyleSheet.OPEN_FOLDER_BUTTON)
|
|
self.open_folder_btn.clicked.connect(self.open_download_folder)
|
|
self.open_folder_btn.setVisible(False) # Hidden by default
|
|
status_layout.addWidget(self.open_folder_btn)
|
|
|
|
progress_layout.addLayout(status_layout)
|
|
|
|
layout.addLayout(progress_layout)
|
|
|
|
# Connect signals
|
|
self.signals.update_formats.connect(self.update_format_table)
|
|
self.signals.update_status.connect(self.set_status_message_animated)
|
|
self.signals.update_progress.connect(self.update_progress_bar)
|
|
|
|
# Connect new signals
|
|
self.signals.playlist_info_label_visible.connect(lambda v: self.set_widget_visible_animated(self.playlist_info_label, v))
|
|
self.signals.playlist_info_label_text.connect(self.playlist_info_label.setText)
|
|
self.signals.selected_subs_label_text.connect(self.selected_subs_label.setText)
|
|
self.signals.playlist_select_btn_visible.connect(lambda v: self.set_widget_visible_animated(self.playlist_select_btn, v))
|
|
self.signals.playlist_select_btn_text.connect(self.playlist_select_btn.setText)
|
|
|
|
# Disable analysis-dependent controls until video is analyzed
|
|
self.toggle_analysis_dependent_controls(enabled=False)
|
|
|
|
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()))
|
|
|
|
|
|
|
|
def paste_url(self) -> None:
|
|
clipboard = QApplication.clipboard()
|
|
self.url_input.setText(clipboard.text())
|
|
|
|
def show_download_settings_dialog(self) -> None: # Renamed method
|
|
dialog = DownloadSettingsDialog(self.last_path, self.speed_limit_value, self.speed_limit_unit_index, self)
|
|
if self.run_dialog_with_blur(dialog):
|
|
# Update Path
|
|
new_path = dialog.get_selected_path()
|
|
path_changed = False
|
|
if new_path != self.last_path:
|
|
self.last_path = new_path
|
|
save_path(self, self.last_path) # Save the updated path
|
|
path_changed = True
|
|
logger.info(f"Download path updated to: {self.last_path}")
|
|
|
|
# Update Speed Limit
|
|
new_limit_value = dialog.get_selected_speed_limit()
|
|
new_unit_index = dialog.get_selected_unit_index()
|
|
limit_changed = False
|
|
if new_limit_value != self.speed_limit_value or new_unit_index != self.speed_limit_unit_index:
|
|
self.speed_limit_value = new_limit_value
|
|
self.speed_limit_unit_index = new_unit_index
|
|
limit_changed = True
|
|
logger.info(
|
|
f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}"
|
|
)
|
|
|
|
# Update Output Format Settings
|
|
new_force_format = dialog.get_force_format_enabled()
|
|
new_preferred_format = dialog.get_preferred_format()
|
|
format_changed = False
|
|
if new_force_format != self.force_output_format or new_preferred_format != self.preferred_output_format:
|
|
self.force_output_format = new_force_format
|
|
self.preferred_output_format = new_preferred_format
|
|
format_changed = True
|
|
logger.info(f"Output format settings updated - Force: {self.force_output_format}, Preferred: {self.preferred_output_format}")
|
|
|
|
# Update Audio Format Settings
|
|
new_force_audio_format = dialog.get_force_audio_format_enabled()
|
|
new_preferred_audio_format = dialog.get_preferred_audio_format()
|
|
audio_format_changed = False
|
|
if new_force_audio_format != self.force_audio_format or new_preferred_audio_format != self.preferred_audio_format:
|
|
self.force_audio_format = new_force_audio_format
|
|
self.preferred_audio_format = new_preferred_audio_format
|
|
audio_format_changed = True
|
|
logger.info(f"Audio format settings updated - Force: {self.force_audio_format}, Preferred: {self.preferred_audio_format}")
|
|
|
|
# Update Tooltip if anything changed
|
|
if path_changed or limit_changed or format_changed or audio_format_changed:
|
|
limit_text = _("main_ui.speed_limit_none")
|
|
if self.speed_limit_value:
|
|
limit_text = f"{self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index]}"
|
|
self.settings_button.setToolTip(
|
|
_(
|
|
"main_ui.settings_tooltip",
|
|
path=self.last_path,
|
|
speed_limit=limit_text,
|
|
)
|
|
)
|
|
|
|
def start_download(self) -> None:
|
|
if self.is_updating_ytdlp:
|
|
QMessageBox.warning(self, _("update.update_in_progress_title"), _("update.update_in_progress_message"))
|
|
return
|
|
|
|
url = self.url_input.text().strip()
|
|
# --- Use self.last_path instead of reading from QLineEdit ---
|
|
path = self.last_path
|
|
|
|
if not url or not path:
|
|
# More specific error message if path is missing
|
|
if not path:
|
|
self.set_status_message_animated(_('download.please_set_path'))
|
|
self.animate_widget_shake(self.settings_button)
|
|
elif not url:
|
|
self.set_status_message_animated(_('download.please_enter_url'))
|
|
self.animate_widget_shake(self.url_input)
|
|
else:
|
|
self.set_status_message_animated(_('download.please_enter_url_and_path'))
|
|
self.animate_widget_shake(self.url_input)
|
|
self.animate_widget_shake(self.settings_button)
|
|
return
|
|
# --- End Path Change ---
|
|
|
|
# Validate URL before starting download
|
|
is_valid, error_message = validate_video_url(url)
|
|
if not is_valid:
|
|
QMessageBox.warning(self, _("main_ui.error_title"), error_message)
|
|
self.animate_widget_shake(self.url_input)
|
|
return
|
|
|
|
# Get selected format
|
|
selected_format = self.get_selected_format()
|
|
if not selected_format:
|
|
self.set_status_message_animated(_('download.please_select_format'))
|
|
self.animate_widget_shake(self.format_table)
|
|
return
|
|
format_id = selected_format["format_id"]
|
|
is_audio_only = bool(selected_format.get("is_audio_only"))
|
|
format_has_audio = bool(selected_format.get("has_audio"))
|
|
|
|
# Show preparation message
|
|
self.status_label.setText(_('download.preparing'))
|
|
self.progress_bar.setValue(0) # Reset progress (range is 0-10000)
|
|
self.open_folder_btn.setVisible(False) # Hide the open folder button on new download
|
|
|
|
# Get resolution for filename
|
|
resolution = "default"
|
|
for checkbox in self.format_checkboxes:
|
|
if checkbox.isChecked():
|
|
parts = checkbox.text().split("•")
|
|
if len(parts) >= 1:
|
|
resolution = parts[0].strip().lower()
|
|
break
|
|
|
|
# Get subtitle selection if available - Now get the list
|
|
selected_subs = self.selected_subtitles if hasattr(self, "selected_subtitles") else []
|
|
|
|
# Get playlist selection IF in playlist mode - USE STORED VALUE
|
|
playlist_items_to_download = None
|
|
if self.is_playlist:
|
|
playlist_items_to_download = self.selected_playlist_items # Use the stored selection string
|
|
|
|
# --- Use stored speed limit values ---
|
|
rate_limit = None
|
|
if self.speed_limit_value:
|
|
try:
|
|
limit_value = float(self.speed_limit_value)
|
|
if self.speed_limit_unit_index == 0: # KB/s
|
|
rate_limit = f"{int(limit_value * 1024)}"
|
|
elif self.speed_limit_unit_index == 1: # MB/s
|
|
rate_limit = f"{int(limit_value * 1024 * 1024)}"
|
|
except ValueError:
|
|
# Use a signal to show error in status bar, similar to URL/Path errors
|
|
self.signals.update_status.emit(_("errors.invalid_speed_limit"))
|
|
return
|
|
# --- End speed limit update ---
|
|
|
|
# Save thumbnail if enabled
|
|
if self.save_thumbnail:
|
|
# Consider moving thumbnail download *after* successful video download
|
|
# Or handle errors more gracefully if thumbnail download fails
|
|
try:
|
|
self.download_thumbnail_file(url, path)
|
|
except Exception as e:
|
|
logger.warning(f"Thumbnail download failed: {e}", exc_info=True)
|
|
# Optionally inform the user, but don't stop the main download
|
|
|
|
# Create download thread with resolution in output template
|
|
self.download_thread = DownloadThread(
|
|
url=url,
|
|
path=path,
|
|
format_id=format_id,
|
|
is_audio_only=is_audio_only,
|
|
format_has_audio=format_has_audio,
|
|
subtitle_langs=selected_subs, # Pass the list of selected subs
|
|
is_playlist=self.is_playlist, # Use the flag directly
|
|
merge_subs=self.merge_subs_checkbox.isChecked(),
|
|
enable_sponsorblock=len(self.selected_sponsorblock_categories) > 0,
|
|
sponsorblock_categories=self.selected_sponsorblock_categories,
|
|
resolution=resolution,
|
|
playlist_items=playlist_items_to_download, # Pass the selection string
|
|
save_description=self.save_description, # Pass the new flag here
|
|
embed_chapters=self.embed_chapters, # Pass the embed chapters flag
|
|
cookie_file=self.cookie_file_path, # Pass the cookie file path
|
|
browser_cookies=self.browser_cookies_option, # Pass the browser cookies option
|
|
rate_limit=rate_limit, # Pass the calculated rate limit
|
|
download_section=self.download_section, # Pass the download section
|
|
force_keyframes=self.force_keyframes, # Pass the force keyframes setting
|
|
proxy_url=self.proxy_url, # Pass the proxy URL
|
|
geo_proxy_url=self.geo_proxy_url, # Pass the geo-verification proxy URL
|
|
force_output_format=self.force_output_format, # Pass force output format setting
|
|
preferred_output_format=self.preferred_output_format, # Pass preferred format
|
|
force_audio_format=self.force_audio_format, # Pass force audio format setting
|
|
preferred_audio_format=self.preferred_audio_format, # Pass preferred audio format
|
|
)
|
|
|
|
# Connect signals
|
|
self.download_thread.progress_signal.connect(self.update_progress_bar)
|
|
self.download_thread.status_signal.connect(self.set_status_message_animated)
|
|
self.download_thread.update_details.connect(self.download_details_label.setText)
|
|
self.download_thread.finished_signal.connect(self.download_finished)
|
|
self.download_thread.error_signal.connect(self.download_error)
|
|
self.download_thread.file_exists_signal.connect(self.file_already_exists)
|
|
|
|
# Reset download state
|
|
self.download_paused = False
|
|
self.download_cancelled = False
|
|
|
|
# Show pause/cancel buttons
|
|
self.pause_btn.setText(_("buttons.pause"))
|
|
self.animate_widget_fade_in(self.pause_btn)
|
|
self.animate_widget_fade_in(self.cancel_btn)
|
|
|
|
# Start download thread
|
|
self.current_download = self.download_thread
|
|
self.download_thread.start()
|
|
self.toggle_download_controls(False)
|
|
|
|
def download_finished(self) -> None:
|
|
self.toggle_download_controls(True)
|
|
self.animate_widget_fade_out(self.pause_btn)
|
|
self.animate_widget_fade_out(self.cancel_btn)
|
|
self.progress_bar.setValue(10000) # 100% in 0-10000 range
|
|
|
|
# Set completion message based on the file type of last downloaded file
|
|
if self.download_thread and self.download_thread.current_filename:
|
|
filename = Path(self.download_thread.current_filename)
|
|
ext = filename.suffix.lower()
|
|
|
|
# Video file extensions
|
|
if ext in VIDEO_EXTENSIONS:
|
|
self.set_status_message_animated(_('download.video_completed'))
|
|
# Audio file extensions
|
|
elif ext in AUDIO_EXTENSIONS:
|
|
self.set_status_message_animated(_('download.audio_completed'))
|
|
# Subtitle file extensions
|
|
elif ext in SUBTITLE_EXTENSIONS:
|
|
self.set_status_message_animated(_('download.subtitle_completed'))
|
|
# Default case
|
|
else:
|
|
self.set_status_message_animated(_('download.completed'))
|
|
|
|
# Show the open folder button
|
|
self.animate_widget_fade_in(self.open_folder_btn)
|
|
|
|
# Save to history
|
|
try:
|
|
if self.download_thread.last_file_path and self.video_info:
|
|
# Get video information
|
|
title = self.video_info.get("title", _("video_info.unknown_title"))
|
|
channel = self.video_info.get("channel", None) or self.video_info.get("uploader", None)
|
|
duration = self.video_info.get("duration_string", None)
|
|
|
|
# Prepare download options
|
|
download_options = {
|
|
"format_id": self.download_thread.format_id,
|
|
"subtitle_langs": self.download_thread.subtitle_langs,
|
|
"merge_subs": self.download_thread.merge_subs,
|
|
"enable_sponsorblock": self.download_thread.enable_sponsorblock,
|
|
"sponsorblock_categories": self.download_thread.sponsorblock_categories,
|
|
"save_description": self.download_thread.save_description,
|
|
"embed_chapters": self.download_thread.embed_chapters,
|
|
"download_section": self.download_thread.download_section,
|
|
"force_keyframes": self.download_thread.force_keyframes,
|
|
}
|
|
|
|
# Add to history
|
|
HistoryManager.add_entry(
|
|
title=title,
|
|
url=self.video_url,
|
|
thumbnail_url=self.thumbnail_url,
|
|
file_path=str(self.download_thread.last_file_path),
|
|
format_id=self.download_thread.format_id,
|
|
is_audio_only=self.download_thread.is_audio_only,
|
|
resolution=self.download_thread.resolution,
|
|
channel=channel,
|
|
duration=duration,
|
|
download_options=download_options,
|
|
)
|
|
logger.info(f"Added download to history: {title}")
|
|
except Exception as e:
|
|
logger.error(f"Error saving to history: {e}", exc_info=True)
|
|
|
|
# Play notification sound when download completes
|
|
self.play_notification_sound()
|
|
|
|
def open_download_folder(self) -> None:
|
|
"""Open the folder containing the downloaded file and select it if possible"""
|
|
try:
|
|
if self.download_thread and self.download_thread.last_file_path:
|
|
file_path = Path(self.download_thread.last_file_path)
|
|
|
|
if file_path.exists():
|
|
# On Windows, use explorer with /select to highlight the file
|
|
if subprocess.sys.platform == "win32":
|
|
subprocess.run(['explorer', '/select,', str(file_path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
|
# On macOS, use open with -R to reveal in Finder
|
|
elif subprocess.sys.platform == "darwin":
|
|
subprocess.run(['open', '-R', str(file_path)])
|
|
# On Linux, try to open the folder (file selection not widely supported)
|
|
else:
|
|
folder_path = file_path.parent
|
|
subprocess.run(['xdg-open', str(folder_path)])
|
|
|
|
logger.info(f"Opened folder for: {file_path}")
|
|
else:
|
|
# If file doesn't exist, just open the download folder
|
|
folder_path = Path(self.last_path)
|
|
if folder_path.exists():
|
|
if subprocess.sys.platform == "win32":
|
|
subprocess.run(['explorer', str(folder_path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
|
elif subprocess.sys.platform == "darwin":
|
|
subprocess.run(['open', str(folder_path)])
|
|
else:
|
|
subprocess.run(['xdg-open', str(folder_path)])
|
|
else:
|
|
logger.warning(f"Download folder does not exist: {folder_path}")
|
|
else:
|
|
# Fallback to opening the general download folder
|
|
folder_path = Path(self.last_path)
|
|
if folder_path.exists():
|
|
if subprocess.sys.platform == "win32":
|
|
subprocess.run(['explorer', str(folder_path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
|
elif subprocess.sys.platform == "darwin":
|
|
subprocess.run(['open', str(folder_path)])
|
|
else:
|
|
subprocess.run(['xdg-open', str(folder_path)])
|
|
else:
|
|
logger.warning(f"Download folder does not exist: {folder_path}")
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error opening download folder: {e}")
|
|
QMessageBox.warning(self, _("main_ui.error_title"), _("main_ui.open_folder_error", error=str(e)))
|
|
|
|
def download_error(self, error_message) -> None:
|
|
self.toggle_download_controls(True)
|
|
self.pause_btn.setVisible(False)
|
|
self.cancel_btn.setVisible(False)
|
|
self.status_label.setText(_("errors.generic_error", error=error_message))
|
|
self.download_details_label.setText("") # Clear details label on error
|
|
|
|
def update_progress_bar(self, value) -> None:
|
|
try:
|
|
# Scale float percentage (0-100) to progress bar range (0-10000) for precision
|
|
scaled_value = int(float(value) * 100)
|
|
|
|
# Use smooth animation for progress updates
|
|
if self._progress_animation.state() == QPropertyAnimation.State.Running:
|
|
self._progress_animation.stop()
|
|
|
|
current_value = self.progress_bar.value()
|
|
# Only animate if there's a meaningful change (avoid micro-animations)
|
|
if abs(scaled_value - current_value) > 10: # More than 0.1% change
|
|
self._progress_animation.setStartValue(current_value)
|
|
self._progress_animation.setEndValue(scaled_value)
|
|
self._progress_animation.start()
|
|
else:
|
|
self.progress_bar.setValue(scaled_value)
|
|
except Exception as e:
|
|
logger.exception(f"Progress bar update error: {e}")
|
|
|
|
def toggle_pause(self) -> None:
|
|
if self.current_download:
|
|
self.current_download.paused = not self.current_download.paused
|
|
if self.current_download.paused:
|
|
self.pause_btn.setText(_("buttons.resume"))
|
|
self.signals.update_status.emit(_("download.paused"))
|
|
else:
|
|
self.pause_btn.setText(_("buttons.pause"))
|
|
self.signals.update_status.emit(_("download.resumed"))
|
|
|
|
def check_for_updates(self) -> None:
|
|
"""Starts the update check in a background thread."""
|
|
self.update_thread = UpdateCheckThread(self.version)
|
|
self.update_thread.update_available.connect(self.show_update_dialog)
|
|
self.update_thread.start()
|
|
|
|
def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter
|
|
msg = QDialog(self)
|
|
msg.setWindowTitle(_("update_dialog.title"))
|
|
msg.setMinimumWidth(600) # Increased width for better layout
|
|
msg.setMinimumHeight(450) # Increased height for better spacing
|
|
|
|
# Set custom icon directly
|
|
try:
|
|
if self.windowIcon() and not self.windowIcon().isNull():
|
|
msg.setWindowIcon(self.windowIcon())
|
|
else:
|
|
# Fallback to icon file
|
|
# icon_path logic moved to src\utils\ytsage_constants.py
|
|
if ICON_PATH.exists():
|
|
msg.setWindowIcon(QIcon(str(ICON_PATH)))
|
|
except Exception:
|
|
pass
|
|
|
|
layout = QVBoxLayout(msg)
|
|
layout.setSpacing(15) # Increased spacing for better layout
|
|
layout.setContentsMargins(20, 20, 20, 20) # Added margins
|
|
|
|
# Header with icon and title
|
|
header_layout = QHBoxLayout()
|
|
|
|
# Add update icon
|
|
icon_label = QLabel()
|
|
icon_label.setPixmap(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload).pixmap(32, 32))
|
|
header_layout.addWidget(icon_label)
|
|
|
|
# Title
|
|
title_label = QLabel(f"<h2 style='color: #c90000; margin: 0;'>{_('update_dialog.title')}</h2>")
|
|
header_layout.addWidget(title_label)
|
|
header_layout.addStretch()
|
|
|
|
layout.addLayout(header_layout)
|
|
|
|
# Update message with better formatting
|
|
message_label = QLabel(
|
|
f"<div style='font-size: 13px; line-height: 1.4;'>"
|
|
f"<b style='color: #ffffff;'>{_('update_dialog.new_version_available')}</b><br><br>"
|
|
f"<span style='color: #cccccc;'>{_('update_dialog.current_version_label')} <b style='color: #ffffff;'>{self.version}</b></span><br>"
|
|
f"<span style='color: #cccccc;'>{_('update_dialog.latest_version_label')} <b style='color: #00ff88;'>{latest_version}</b></span>"
|
|
f"</div>"
|
|
)
|
|
message_label.setWordWrap(True)
|
|
message_label.setStyleSheet(StyleSheet.UPDATE_DIALOG_MESSAGE)
|
|
layout.addWidget(message_label)
|
|
|
|
# Changelog Section
|
|
changelog_label = QLabel(f"<b style='color: #ffffff; font-size: 14px;'>{_('update_dialog.changelog')}:</b>")
|
|
changelog_label.setStyleSheet("padding: 5px 0; margin-top: 10px;")
|
|
layout.addWidget(changelog_label)
|
|
|
|
changelog_text = QTextEdit()
|
|
changelog_text.setReadOnly(True)
|
|
# Convert Markdown to HTML and set it
|
|
try:
|
|
html_changelog = markdown.markdown(
|
|
changelog,
|
|
extensions=[
|
|
"markdown.extensions.tables",
|
|
"markdown.extensions.fenced_code",
|
|
],
|
|
)
|
|
changelog_text.setHtml(html_changelog)
|
|
except Exception as e:
|
|
logger.warning(f"Error converting changelog markdown to HTML: {e}", exc_info=True)
|
|
changelog_text.setPlainText(changelog) # Fallback to plain text
|
|
|
|
changelog_text.setStyleSheet(StyleSheet.UPDATE_DIALOG_CHANGELOG)
|
|
changelog_text.setMaximumHeight(180) # Limit height
|
|
layout.addWidget(changelog_text)
|
|
|
|
# Buttons with better styling
|
|
button_layout = QHBoxLayout()
|
|
button_layout.setSpacing(10)
|
|
|
|
download_btn = QPushButton(_('update_dialog.download_update'))
|
|
download_btn.clicked.connect(lambda: self.open_release_page(release_url))
|
|
download_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_DOWNLOAD_BTN)
|
|
|
|
remind_btn = QPushButton(_('update_dialog.remind_later'))
|
|
remind_btn.clicked.connect(msg.close)
|
|
remind_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
|
|
|
|
button_layout.addStretch()
|
|
button_layout.addWidget(download_btn)
|
|
button_layout.addWidget(remind_btn)
|
|
layout.addLayout(button_layout)
|
|
|
|
# Style the dialog with improved theme matching
|
|
msg.setStyleSheet(StyleSheet.UPDATE_DIALOG_MAIN)
|
|
|
|
self.run_dialog_with_blur(msg)
|
|
|
|
def open_release_page(self, url):
|
|
webbrowser.open(url)
|
|
|
|
def check_auto_update_ytdlp(self) -> None:
|
|
"""Check and perform auto-update for yt-dlp if enabled and due."""
|
|
try:
|
|
# Check if auto-update should be performed
|
|
if should_check_for_auto_update():
|
|
logger.info("Performing auto-update check for yt-dlp...")
|
|
# Perform the auto-update in a non-blocking way
|
|
# We don't want to block the UI startup for this
|
|
QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup
|
|
except Exception as e:
|
|
logger.exception(f"Error in auto-update check: {e}")
|
|
|
|
def _perform_auto_update(self) -> None:
|
|
"""Actually perform the auto-update check and update if needed in a background thread."""
|
|
# Check if a download is currently running or analysis is in progress
|
|
if (self.current_download and self.current_download.isRunning()) or self.is_analyzing:
|
|
logger.info("Download or analysis in progress, skipping auto-update check.")
|
|
return
|
|
|
|
try:
|
|
self.is_updating_ytdlp = True # Set flag
|
|
# Create and start the auto-update thread to avoid blocking the UI
|
|
|
|
self.auto_update_thread = AutoUpdateThread()
|
|
self.auto_update_thread.update_finished.connect(self._on_auto_update_finished)
|
|
self.auto_update_thread.start()
|
|
except Exception as e:
|
|
self.is_updating_ytdlp = False # Reset flag on error
|
|
logger.exception(f"Error starting auto-update thread: {e}")
|
|
|
|
def _on_auto_update_finished(self, success, message) -> None:
|
|
self.is_updating_ytdlp = False # Reset flag
|
|
"""Handle auto-update completion."""
|
|
if success:
|
|
logger.info(f"Auto-update completed successfully: {message}")
|
|
else:
|
|
logger.warning(f"Auto-update completed with issues: {message}")
|
|
|
|
# Clean up the thread reference and ensure it's properly finished
|
|
if hasattr(self, "auto_update_thread"):
|
|
# Disconnect all signals to prevent further callbacks
|
|
self.auto_update_thread.update_finished.disconnect()
|
|
# Make sure thread is finished
|
|
if self.auto_update_thread.isRunning():
|
|
self.auto_update_thread.quit()
|
|
self.auto_update_thread.wait(1000) # Wait up to 1 second
|
|
# Remove the reference
|
|
delattr(self, "auto_update_thread")
|
|
|
|
def closeEvent(self, event) -> None:
|
|
"""Handle application close event to ensure proper cleanup of background threads."""
|
|
try:
|
|
# Stop the analysis thread if it's running
|
|
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
|
|
logger.info("Stopping analysis thread...")
|
|
self._analysis_thread.cancel()
|
|
if not self._analysis_thread.wait(2000): # Wait up to 2 seconds
|
|
logger.warning("Force terminating analysis thread...")
|
|
self._analysis_thread.terminate()
|
|
self._analysis_thread.wait(1000)
|
|
|
|
# Stop the auto-update thread if it's running
|
|
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
|
|
logger.info("Stopping auto-update thread...")
|
|
self.auto_update_thread.quit()
|
|
if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown
|
|
logger.warning("Force terminating auto-update thread...")
|
|
self.auto_update_thread.terminate()
|
|
self.auto_update_thread.wait(1000) # Wait for termination
|
|
|
|
# Cancel any running downloads
|
|
if self.current_download and self.current_download.isRunning():
|
|
logger.info("Canceling running download...")
|
|
self.current_download.cancel()
|
|
if not self.current_download.wait(3000): # Wait up to 3 seconds for graceful shutdown
|
|
logger.warning("Force terminating download thread...")
|
|
self.current_download.terminate()
|
|
self.current_download.wait(1000) # Wait for termination
|
|
|
|
logger.info("Application closing...")
|
|
event.accept()
|
|
except Exception as e:
|
|
logger.exception(f"Error during application close: {e}")
|
|
event.accept() # Accept the close event anyway
|
|
|
|
def show_custom_options(self) -> None:
|
|
dialog = CustomOptionsDialog(self)
|
|
if self.run_dialog_with_blur(dialog):
|
|
# Handle proxy options
|
|
proxy_url = dialog.get_proxy_url()
|
|
geo_proxy_url = dialog.get_geo_proxy_url()
|
|
|
|
# Update instance variables
|
|
self.proxy_url = proxy_url
|
|
self.geo_proxy_url = geo_proxy_url
|
|
|
|
# Save proxy settings to config
|
|
ConfigManager.set("proxy_url", proxy_url)
|
|
ConfigManager.set("geo_proxy_url", geo_proxy_url)
|
|
|
|
# Show confirmation messages
|
|
if proxy_url:
|
|
logger.info(f"Main proxy set: {self.proxy_url}")
|
|
QMessageBox.information(
|
|
self,
|
|
_("proxy.set_title"),
|
|
_("proxy.set_message", proxy=proxy_url),
|
|
)
|
|
|
|
if geo_proxy_url:
|
|
logger.info(f"Geo-verification proxy set: {self.geo_proxy_url}")
|
|
QMessageBox.information(
|
|
self,
|
|
_("proxy.geo_set_title"),
|
|
_("proxy.geo_set_message", proxy=geo_proxy_url),
|
|
)
|
|
|
|
# Show a combined message if both are cleared
|
|
if not proxy_url and not geo_proxy_url and (ConfigManager.get("proxy_url") or ConfigManager.get("geo_proxy_url")):
|
|
QMessageBox.information(
|
|
self,
|
|
_("proxy.cleared_title"),
|
|
_("proxy.cleared_message"),
|
|
)
|
|
|
|
def show_about_dialog(self) -> None: # ADDED METHOD HERE
|
|
dialog = AboutDialog(self)
|
|
self.run_dialog_with_blur(dialog)
|
|
|
|
def show_history_dialog(self) -> None:
|
|
"""Show the download history dialog."""
|
|
dialog = HistoryDialog(self)
|
|
dialog.redownload_requested.connect(self.handle_redownload_from_history)
|
|
self.run_dialog_with_blur(dialog)
|
|
|
|
def handle_redownload_from_history(self, entry: dict) -> None:
|
|
"""Handle redownload request from history dialog."""
|
|
try:
|
|
# Set URL
|
|
url = entry.get("url", "")
|
|
if url:
|
|
self.url_input.setText(url)
|
|
self.video_url = url
|
|
|
|
# Analyze the URL to get format info
|
|
logger.info(f"Redownloading from history: {entry.get('title', 'Unknown')}")
|
|
QMessageBox.information(
|
|
self,
|
|
_("history.redownload_started"),
|
|
_("history.redownload_started") + f"\n\n{entry.get('title', '')}"
|
|
)
|
|
|
|
# Trigger analysis
|
|
self.analyze_url()
|
|
else:
|
|
QMessageBox.warning(self, _("main_ui.error_title"), _("history.no_url_error"))
|
|
except Exception as e:
|
|
logger.error(f"Error handling redownload from history: {e}", exc_info=True)
|
|
QMessageBox.warning(self, _("main_ui.error_title"), _("history.redownload_failed", error=str(e)))
|
|
|
|
def file_already_exists(self, filename) -> None:
|
|
"""Handle case when file already exists - simplified version"""
|
|
self.toggle_download_controls(True)
|
|
self.pause_btn.setVisible(False)
|
|
self.cancel_btn.setVisible(False)
|
|
self.progress_bar.setValue(10000) # 100% in 0-10000 range
|
|
|
|
# Determine file type based on extension
|
|
ext = Path(filename).suffix.lower()
|
|
|
|
# Video file extensions
|
|
if ext in VIDEO_EXTENSIONS:
|
|
self.status_label.setText(_("status.video_file_exists"))
|
|
# Audio file extensions
|
|
elif ext in AUDIO_EXTENSIONS:
|
|
self.status_label.setText(_("status.audio_file_exists"))
|
|
# Subtitle file extensions
|
|
elif ext in SUBTITLE_EXTENSIONS:
|
|
self.status_label.setText(_("status.subtitle_file_exists"))
|
|
# Default case
|
|
else:
|
|
self.status_label.setText(_("status.file_exists"))
|
|
|
|
# Show a simple message dialog
|
|
msg_box = QMessageBox()
|
|
msg_box.setIcon(QMessageBox.Icon.Information)
|
|
msg_box.setWindowTitle(_("file_exists_dialog.title"))
|
|
msg_box.setText(_("file_exists_dialog.message", filename=filename))
|
|
msg_box.setInformativeText(_("file_exists_dialog.info"))
|
|
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
|
|
|
|
# Set the window icon to match the main application
|
|
msg_box.setWindowIcon(self.windowIcon())
|
|
|
|
# Style the dialog
|
|
msg_box.setStyleSheet(StyleSheet.FILE_EXISTS_DIALOG)
|
|
|
|
msg_box.exec()
|
|
|
|
|
|
# --- Add Toggle Methods Here ---
|
|
def toggle_save_thumbnail(self, state) -> None:
|
|
logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state
|
|
self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state)
|
|
logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}")
|
|
|
|
def toggle_save_description(self, state) -> None:
|
|
logger.debug(f"Raw description state received: {state}") # Debug: Print raw state
|
|
self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state)
|
|
logger.debug(f"Save description toggled: {self.save_description}")
|
|
|
|
def toggle_embed_chapters(self, state) -> None:
|
|
logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state
|
|
self.embed_chapters = bool(state == 2) # Compare state directly with 2 (Checked state)
|
|
logger.debug(f"Embed chapters toggled: {self.embed_chapters}")
|
|
|
|
# --- End Toggle Methods ---
|
|
|
|
def open_playlist_selection_dialog(self) -> None:
|
|
if not self.is_playlist or not self.playlist_entries:
|
|
logger.info("No playlist data available to select from.")
|
|
return
|
|
|
|
dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self)
|
|
|
|
if self.run_dialog_with_blur(dialog):
|
|
self.selected_playlist_items = dialog.get_selected_items_string()
|
|
logger.info(f"Playlist items selected: {self.selected_playlist_items}")
|
|
|
|
# Update button text (this call is safe as it happens in the main thread after dialog closes)
|
|
if self.selected_playlist_items is None:
|
|
button_text = "Select Videos... (All selected)"
|
|
else:
|
|
selected_indices = dialog._parse_selection_string(self.selected_playlist_items)
|
|
count = len(selected_indices)
|
|
display_text = (
|
|
self.selected_playlist_items if len(self.selected_playlist_items) < 30 else f"{count} videos selected"
|
|
)
|
|
button_text = f"Select Videos... ({display_text})"
|
|
self.playlist_select_btn.setText(button_text) # Direct call is fine here
|
|
|
|
# --- New Slot for Updating Playlist Button Text ---
|
|
# moved to SignalManager as Signal and added to init_ui() method.
|
|
|
|
def toggle_download_controls(self, enabled=True) -> None:
|
|
"""Enable or disable download-related controls"""
|
|
self.url_input.setEnabled(enabled)
|
|
# Analyze button should only be enabled if there's text in the URL input
|
|
if enabled:
|
|
self.analyze_button.setEnabled(bool(self.url_input.text().strip()))
|
|
else:
|
|
self.analyze_button.setEnabled(False)
|
|
self.format_table.setEnabled(enabled) # Changed from format_scroll_area to format_table
|
|
self.download_btn.setEnabled(enabled)
|
|
if hasattr(self, "subtitle_combo"):
|
|
self.subtitle_combo.setEnabled(enabled) # type: ignore[reportAttributeAccessIssue]
|
|
self.video_button.setEnabled(enabled)
|
|
self.audio_button.setEnabled(enabled)
|
|
if hasattr(self, "sponsorblock_select_btn"):
|
|
self.sponsorblock_select_btn.setEnabled(enabled)
|
|
self.merge_subs_checkbox.setEnabled(enabled) # Enable/disable merge subs checkbox
|
|
self.custom_options_btn.setEnabled(enabled) # Enable/disable custom options button
|
|
self.time_range_btn.setEnabled(enabled) # Enable/disable time range button
|
|
self.settings_button.setEnabled(enabled) # Enable/disable settings button
|
|
|
|
# Clear progress/status when controls are re-enabled
|
|
if enabled:
|
|
self.progress_bar.setValue(0) # Reset progress (range is 0-10000)
|
|
self.status_label.setText(_("status.ready"))
|
|
self.download_details_label.setText("") # Clear details label
|
|
|
|
@Slot(bool)
|
|
def toggle_analysis_dependent_controls(self, enabled=True) -> None:
|
|
"""Enable or disable controls that require video analysis to be completed"""
|
|
# Determine tooltip text for disabled state
|
|
tooltip_text = "" if enabled else _("main_ui.analyze_first_tooltip")
|
|
|
|
# Subtitle selection
|
|
if hasattr(self, "subtitle_select_btn"):
|
|
self.subtitle_select_btn.setEnabled(enabled)
|
|
if not enabled:
|
|
self.subtitle_select_btn.setToolTip(tooltip_text)
|
|
else:
|
|
self.subtitle_select_btn.setToolTip("")
|
|
|
|
# SponsorBlock (only if not in audio mode)
|
|
if hasattr(self, "sponsorblock_select_btn"):
|
|
is_audio_mode = self.audio_button.isChecked()
|
|
self.sponsorblock_select_btn.setEnabled(enabled and not is_audio_mode)
|
|
if not enabled or is_audio_mode:
|
|
self.sponsorblock_select_btn.setToolTip(tooltip_text if not enabled else _("main_ui.audio_mode_disabled"))
|
|
else:
|
|
self.sponsorblock_select_btn.setToolTip("")
|
|
|
|
# Save Thumbnail checkbox
|
|
if hasattr(self, "save_thumbnail_checkbox"):
|
|
self.save_thumbnail_checkbox.setEnabled(enabled)
|
|
if not enabled:
|
|
self.save_thumbnail_checkbox.setToolTip(tooltip_text)
|
|
else:
|
|
self.save_thumbnail_checkbox.setToolTip("")
|
|
|
|
# Save Description checkbox
|
|
if hasattr(self, "save_description_checkbox"):
|
|
self.save_description_checkbox.setEnabled(enabled)
|
|
if not enabled:
|
|
self.save_description_checkbox.setToolTip(tooltip_text)
|
|
else:
|
|
self.save_description_checkbox.setToolTip("")
|
|
|
|
# Embed Chapters checkbox
|
|
if hasattr(self, "embed_chapters_checkbox"):
|
|
self.embed_chapters_checkbox.setEnabled(enabled)
|
|
if not enabled:
|
|
self.embed_chapters_checkbox.setToolTip(tooltip_text)
|
|
else:
|
|
self.embed_chapters_checkbox.setToolTip("")
|
|
|
|
# Merge Subtitles (only if subtitles are selected and not in audio mode)
|
|
if hasattr(self, "merge_subs_checkbox"):
|
|
has_subs = len(getattr(self, "selected_subtitles", [])) > 0
|
|
is_audio_mode = self.audio_button.isChecked()
|
|
should_enable = enabled and has_subs and not is_audio_mode
|
|
self.merge_subs_checkbox.setEnabled(should_enable)
|
|
if not enabled:
|
|
self.merge_subs_checkbox.setToolTip(tooltip_text)
|
|
elif not has_subs:
|
|
self.merge_subs_checkbox.setToolTip(_("main_ui.select_subtitles_first"))
|
|
elif is_audio_mode:
|
|
self.merge_subs_checkbox.setToolTip(_("main_ui.audio_mode_disabled"))
|
|
else:
|
|
self.merge_subs_checkbox.setToolTip("")
|
|
|
|
def handle_format_selection(self, button) -> None:
|
|
# Update formats
|
|
self.filter_formats()
|
|
|
|
def handle_mode_change(self) -> None:
|
|
"""Enable or disable features based on video/audio mode"""
|
|
# Only allow enabling if analysis is complete
|
|
can_enable = self.analysis_completed
|
|
|
|
if self.audio_button.isChecked():
|
|
# In Audio Only mode, disable video-specific features
|
|
if hasattr(self, "sponsorblock_select_btn"):
|
|
self.sponsorblock_select_btn.setEnabled(False)
|
|
self.sponsorblock_select_btn.setToolTip(_("main_ui.audio_mode_disabled"))
|
|
if hasattr(self, "selected_sponsorblock_categories"):
|
|
self.selected_sponsorblock_categories = [] # Clear selection when disabled
|
|
if hasattr(self, "_update_sponsorblock_display"):
|
|
self._update_sponsorblock_display()
|
|
self.merge_subs_checkbox.setEnabled(False)
|
|
self.merge_subs_checkbox.setChecked(False) # Uncheck when disabled
|
|
if not can_enable:
|
|
self.merge_subs_checkbox.setToolTip(_("main_ui.analyze_first_tooltip"))
|
|
else:
|
|
self.merge_subs_checkbox.setToolTip(_("main_ui.audio_mode_disabled"))
|
|
|
|
# Allow subtitle selection in Audio Only mode if analysis is complete
|
|
if hasattr(self, "subtitle_select_btn"):
|
|
self.subtitle_select_btn.setEnabled(can_enable)
|
|
if not can_enable:
|
|
self.subtitle_select_btn.setToolTip(_("main_ui.analyze_first_tooltip"))
|
|
else:
|
|
self.subtitle_select_btn.setToolTip("")
|
|
else:
|
|
# In Video mode, enable video-specific features (if analysis complete)
|
|
if hasattr(self, "sponsorblock_select_btn"):
|
|
self.sponsorblock_select_btn.setEnabled(can_enable)
|
|
if not can_enable:
|
|
self.sponsorblock_select_btn.setToolTip(_("main_ui.analyze_first_tooltip"))
|
|
else:
|
|
self.sponsorblock_select_btn.setToolTip("")
|
|
# Don't automatically restore categories - let user choose when they open the dialog
|
|
|
|
# Enable merge_subs only if subtitles are selected and analysis is complete
|
|
has_subs_selected = len(getattr(self, "selected_subtitles", [])) > 0
|
|
should_enable_merge = can_enable and has_subs_selected
|
|
self.merge_subs_checkbox.setEnabled(should_enable_merge)
|
|
if not can_enable:
|
|
self.merge_subs_checkbox.setToolTip(_("main_ui.analyze_first_tooltip"))
|
|
elif not has_subs_selected:
|
|
self.merge_subs_checkbox.setToolTip(_("main_ui.select_subtitles_first"))
|
|
else:
|
|
self.merge_subs_checkbox.setToolTip("")
|
|
|
|
# Re-enable subtitle selection button in Video mode (if analysis complete)
|
|
if hasattr(self, "subtitle_select_btn"):
|
|
self.subtitle_select_btn.setEnabled(can_enable)
|
|
if not can_enable:
|
|
self.subtitle_select_btn.setToolTip(_("main_ui.analyze_first_tooltip"))
|
|
else:
|
|
self.subtitle_select_btn.setToolTip("")
|
|
|
|
# Keep these methods for backwards compatibility - they just call the new dialog now
|
|
def show_custom_command(self) -> None:
|
|
dialog = CustomOptionsDialog(self)
|
|
dialog.tab_widget.setCurrentIndex(1) # Select the Custom Command tab
|
|
self.run_dialog_with_blur(dialog)
|
|
|
|
def show_cookie_login_dialog(self) -> None:
|
|
dialog = CustomOptionsDialog(self)
|
|
dialog.tab_widget.setCurrentIndex(0) # Select the Cookie Login tab
|
|
if self.run_dialog_with_blur(dialog):
|
|
# Handle cookies
|
|
cookie_path = dialog.get_cookie_file_path()
|
|
browser_cookies = dialog.get_browser_cookies_option()
|
|
|
|
if cookie_path:
|
|
self.cookie_file_path = cookie_path
|
|
self.browser_cookies_option = None # Clear browser cookies if file is used
|
|
logger.info(f"Selected cookie file: {self.cookie_file_path}")
|
|
QMessageBox.information(
|
|
self,
|
|
_("main_ui.cookie_file_selected_title"),
|
|
_("main_ui.cookie_file_selected_message", path=self.cookie_file_path),
|
|
)
|
|
elif browser_cookies:
|
|
self.browser_cookies_option = browser_cookies
|
|
self.cookie_file_path = None # Clear file cookies if browser is used
|
|
logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
|
|
QMessageBox.information(
|
|
self,
|
|
_("main_ui.browser_cookies_selected_title"),
|
|
_("main_ui.browser_cookies_selected_message", browser=browser_cookies),
|
|
)
|
|
else:
|
|
self.cookie_file_path = None # Clear path if dialog accepted but no file selected
|
|
self.browser_cookies_option = None # Clear browser cookies too
|
|
|
|
def cancel_download(self) -> None:
|
|
if self.current_download:
|
|
self.current_download.cancelled = True
|
|
self.set_status_message_animated(_("status.cancelling")) # Set status directly
|
|
self.download_details_label.setText("") # Clear details label on cancellation
|
|
|
|
def show_ffmpeg_dialog(self) -> None:
|
|
dialog = FFmpegCheckDialog(self)
|
|
self.run_dialog_with_blur(dialog)
|
|
|
|
# Add method for showing time range dialog
|
|
def show_time_range_dialog(self) -> None:
|
|
dialog = TimeRangeDialog(self)
|
|
if self.run_dialog_with_blur(dialog):
|
|
# Store the time range settings
|
|
self.download_section = dialog.get_download_sections()
|
|
self.force_keyframes = dialog.get_force_keyframes()
|
|
|
|
if self.download_section:
|
|
self.time_range_btn.setStyleSheet(StyleSheet.TIME_RANGE_BTN_ACTIVE)
|
|
self.time_range_btn.setToolTip(_("main_ui.time_range_set", section=self.download_section))
|
|
else:
|
|
# Reset to default style if no section is selected
|
|
self.download_section = None
|
|
self.force_keyframes = False
|
|
self.time_range_btn.setStyleSheet("")
|
|
self.time_range_btn.setToolTip("")
|
|
|
|
def show_ytdlp_setup_dialog(self) -> None:
|
|
"""Show the yt-dlp setup dialog to configure yt-dlp"""
|
|
yt_dlp_path = setup_ytdlp(self)
|
|
if yt_dlp_path != "yt-dlp":
|
|
success_dialog = QMessageBox(self)
|
|
success_dialog.setIcon(QMessageBox.Icon.Information)
|
|
success_dialog.setWindowTitle(_("ytdlp_setup.success_dialog_title"))
|
|
success_dialog.setText(_("ytdlp_setup.success_dialog_message", path=yt_dlp_path))
|
|
success_dialog.setWindowIcon(self.windowIcon())
|
|
success_dialog.setStyleSheet(StyleSheet.SETUP_SUCCESS_DIALOG)
|
|
self.run_dialog_with_blur(success_dialog)
|
|
|
|
def show_deno_setup_dialog(self) -> None:
|
|
"""Show the Deno setup dialog to configure Deno"""
|
|
deno_path = setup_deno(self)
|
|
if deno_path != "deno":
|
|
success_dialog = QMessageBox(self)
|
|
success_dialog.setIcon(QMessageBox.Icon.Information)
|
|
success_dialog.setWindowTitle(_("deno.setup_required"))
|
|
success_dialog.setText(f"{_('deno.success')}\n{deno_path}")
|
|
success_dialog.setWindowIcon(self.windowIcon())
|
|
success_dialog.setStyleSheet(StyleSheet.SETUP_SUCCESS_DIALOG)
|
|
self.run_dialog_with_blur(success_dialog)
|
|
|
|
def animate_widget_fade_in(self, widget: QWidget, duration: int = 300) -> None:
|
|
"""Fade in a widget using opacity animation."""
|
|
# Check if the main window has a graphics effect (blur) active.
|
|
# If so, animating a child widget with another effect causes QPainter conflicts.
|
|
# NOTE: With the new Screenshot Overlay method, 'self.graphicsEffect()' on the MainWindow isn't used.
|
|
# However, we should still be careful if the widget itself already has an effect.
|
|
|
|
# Stop fade out if running
|
|
if hasattr(widget, '_fade_out_anim'):
|
|
try:
|
|
if widget._fade_out_anim.state() == QPropertyAnimation.State.Running:
|
|
widget._fade_out_anim.stop()
|
|
except RuntimeError:
|
|
pass
|
|
|
|
if widget.isVisible() and widget.graphicsEffect() is None:
|
|
return
|
|
|
|
# Setup opacity effect if not present
|
|
effect = widget.graphicsEffect()
|
|
if not effect or not isinstance(effect, QGraphicsOpacityEffect):
|
|
effect = QGraphicsOpacityEffect(widget)
|
|
widget.setGraphicsEffect(effect)
|
|
|
|
# Determine start value (current opacity if previously animating)
|
|
start_val = effect.opacity() if widget.isVisible() else 0.0
|
|
end_val = 1.0
|
|
|
|
# Setup animation
|
|
anim = QPropertyAnimation(effect, b"opacity", widget)
|
|
anim.setDuration(duration)
|
|
anim.setStartValue(start_val)
|
|
anim.setEndValue(end_val)
|
|
anim.setEasingCurve(QEasingCurve.Type.OutQuad)
|
|
|
|
# Show widget before animation
|
|
widget.setVisible(True)
|
|
anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
|
|
# Keep reference to avoid garbage collection
|
|
widget._fade_anim = anim
|
|
|
|
def animate_widget_fade_out(self, widget: QWidget, duration: int = 300) -> None:
|
|
"""Fade out a widget and then hide it."""
|
|
|
|
# Stop fade in if running
|
|
if hasattr(widget, '_fade_anim'):
|
|
try:
|
|
if widget._fade_anim.state() == QPropertyAnimation.State.Running:
|
|
widget._fade_anim.stop()
|
|
except RuntimeError:
|
|
pass
|
|
|
|
if not widget.isVisible():
|
|
return
|
|
|
|
effect = widget.graphicsEffect()
|
|
if not effect or not isinstance(effect, QGraphicsOpacityEffect):
|
|
effect = QGraphicsOpacityEffect(widget)
|
|
widget.setGraphicsEffect(effect)
|
|
effect.setOpacity(1.0)
|
|
|
|
start_val = effect.opacity()
|
|
|
|
anim = QPropertyAnimation(effect, b"opacity", widget)
|
|
anim.setDuration(duration)
|
|
anim.setStartValue(start_val)
|
|
anim.setEndValue(0.0)
|
|
anim.setEasingCurve(QEasingCurve.Type.InQuad)
|
|
|
|
def on_finished():
|
|
widget.setVisible(False)
|
|
widget.setGraphicsEffect(None) # Remove effect to restore normal painting
|
|
|
|
anim.finished.connect(on_finished)
|
|
anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
|
|
widget._fade_out_anim = anim # Keep reference
|
|
|
|
def set_widget_visible_animated(self, widget: QWidget, visible: bool) -> None:
|
|
"""Toggle widget visibility with fade animation."""
|
|
if visible:
|
|
self.animate_widget_fade_in(widget)
|
|
else:
|
|
self.animate_widget_fade_out(widget)
|
|
|
|
def animate_widget_shake(self, widget: QWidget) -> None:
|
|
"""Shake a widget left and right to indicate an error or invalid input."""
|
|
# Stop shake if running
|
|
if hasattr(widget, '_shake_anim'):
|
|
try:
|
|
if widget._shake_anim.state() == QPropertyAnimation.State.Running:
|
|
return
|
|
except RuntimeError:
|
|
pass
|
|
|
|
# Use current position as baseline
|
|
pos = widget.pos()
|
|
x = pos.x()
|
|
y = pos.y()
|
|
|
|
anim = QPropertyAnimation(widget, b"pos", widget)
|
|
anim.setDuration(300)
|
|
anim.setLoopCount(1)
|
|
|
|
# Create keyframes for shake effect
|
|
anim.setKeyValueAt(0, QPoint(x, y))
|
|
anim.setKeyValueAt(0.2, QPoint(x - 5, y))
|
|
anim.setKeyValueAt(0.4, QPoint(x + 5, y))
|
|
anim.setKeyValueAt(0.6, QPoint(x - 5, y))
|
|
anim.setKeyValueAt(0.8, QPoint(x + 5, y))
|
|
anim.setKeyValueAt(1.0, QPoint(x, y))
|
|
|
|
widget._shake_anim = anim
|
|
anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
|
|
|
|
def set_status_message_animated(self, message: str) -> None:
|
|
"""Update status label with a cross-fade animation."""
|
|
if self.status_label.text() == message:
|
|
return
|
|
|
|
# If previous animation is running, stop it
|
|
if hasattr(self.status_label, '_status_anim'):
|
|
try:
|
|
if self.status_label._status_anim.state() == QPropertyAnimation.State.Running:
|
|
self.status_label._status_anim.stop()
|
|
except RuntimeError:
|
|
pass # Animation object already deleted
|
|
|
|
# Create effect if needed
|
|
effect = self.status_label.graphicsEffect()
|
|
if not effect or not isinstance(effect, QGraphicsOpacityEffect):
|
|
effect = QGraphicsOpacityEffect(self.status_label)
|
|
self.status_label.setGraphicsEffect(effect)
|
|
|
|
# 1. Fade OUT
|
|
anim1 = QPropertyAnimation(effect, b"opacity", self.status_label)
|
|
anim1.setDuration(150)
|
|
anim1.setStartValue(1.0)
|
|
anim1.setEndValue(0.0)
|
|
anim1.setEasingCurve(QEasingCurve.Type.OutQuad)
|
|
|
|
# 2. Change Text & Fade IN
|
|
anim2 = QPropertyAnimation(effect, b"opacity", self.status_label)
|
|
anim2.setDuration(150)
|
|
anim2.setStartValue(0.0)
|
|
anim2.setEndValue(1.0)
|
|
anim2.setEasingCurve(QEasingCurve.Type.InQuad)
|
|
|
|
def on_fade_out_finished():
|
|
self.status_label.setText(message)
|
|
anim2.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
|
|
anim1.finished.connect(on_fade_out_finished)
|
|
anim1.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
|
|
# Store ref
|
|
self.status_label._status_anim = anim1
|
|
self.status_label._status_anim2 = anim2
|
|
|
|
def run_dialog_with_blur(self, dialog: QDialog) -> int:
|
|
"""Run a dialog with a static background screenshot blur to avoid QPainter conflicts."""
|
|
|
|
# 1. Capture the current state of the window (screenshot)
|
|
pixmap = self.grab()
|
|
|
|
# 2. Create the blur using a Graphics Scene method (much safer than QGraphicsBlurEffect on live widget)
|
|
# However, for simplicity and performance with PySide6, we can just apply a blur to the image
|
|
# or use a simplified overlay.
|
|
# Let's manually blur the pixmap or use a simpler transparent overlay if blur is too heavy manually.
|
|
# Actually, using QGraphicsBlurEffect on a temporary QGraphicsScene rendering to a pixmap is a valid way
|
|
# to generate a single blurred frame.
|
|
|
|
blurred_pixmap = self._apply_blur_to_pixmap(pixmap, radius=10)
|
|
|
|
# 3. Create an overlay widget that covers the Main Window
|
|
overlay = QLabel(self)
|
|
overlay.setPixmap(blurred_pixmap)
|
|
overlay.setGeometry(0, 0, self.width(), self.height())
|
|
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Block mouse
|
|
overlay.show()
|
|
|
|
# Animate overlay Fade In
|
|
opacity_effect = QGraphicsOpacityEffect(overlay)
|
|
overlay.setGraphicsEffect(opacity_effect)
|
|
|
|
anim = QPropertyAnimation(opacity_effect, b"opacity", overlay)
|
|
anim.setDuration(250)
|
|
anim.setStartValue(0.0)
|
|
anim.setEndValue(1.0)
|
|
anim.setEasingCurve(QEasingCurve.Type.OutQuad)
|
|
anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
|
|
|
|
# 4. Run the dialog
|
|
# Ensure dialog is on top
|
|
result = dialog.exec()
|
|
|
|
# 5. Remove overlay
|
|
overlay.deleteLater()
|
|
|
|
return result
|
|
|
|
def _apply_blur_to_pixmap(self, source_pixmap: QPixmap, radius: int) -> QPixmap:
|
|
"""Helper to create a blurred version of a pixmap."""
|
|
if source_pixmap.isNull():
|
|
return source_pixmap
|
|
|
|
# Create a QGraphicsScene to apply the effect to the pixmap
|
|
scene = QGraphicsScene()
|
|
item = QGraphicsPixmapItem(source_pixmap)
|
|
scene.addItem(item)
|
|
|
|
blur = QGraphicsBlurEffect()
|
|
blur.setBlurRadius(radius)
|
|
item.setGraphicsEffect(blur)
|
|
|
|
# Render back to pixmap
|
|
result = source_pixmap.copy() # Match size/format
|
|
result.fill(Qt.GlobalColor.transparent)
|
|
|
|
with QPainter(result) as painter:
|
|
scene.render(painter, target=source_pixmap.rect().toRectF(), source=source_pixmap.rect().toRectF())
|
|
|
|
# Optional: Add a dark tint for 'dimming' effect
|
|
painter.setBrush(QBrush(QColor(0, 0, 0, 100))) # 100/255 opacity black
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.drawRect(source_pixmap.rect())
|
|
|
|
return result
|
|
|
|
|