diff --git a/main.py b/main.py index aa512d29..ad2866d5 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ from ui.pages.sync import SyncPage from ui.pages.downloads import DownloadsPage from ui.pages.artists import ArtistsPage from ui.pages.settings import SettingsPage +from ui.components.toast_manager import ToastManager logger = get_logger("main") @@ -154,6 +155,9 @@ class MainWindow(QMainWindow): # Create stacked widget for pages self.stacked_widget = QStackedWidget() + # Create toast manager + self.toast_manager = ToastManager(self) + # Create and add pages self.dashboard_page = DashboardPage() self.downloads_page = DownloadsPage(self.soulseek_client) @@ -161,10 +165,17 @@ class MainWindow(QMainWindow): self.artists_page = ArtistsPage(downloads_page=self.downloads_page) self.settings_page = SettingsPage() + # Set toast manager for pages that need direct access + self.downloads_page.set_toast_manager(self.toast_manager) + self.sync_page.set_toast_manager(self.toast_manager) + self.artists_page.set_toast_manager(self.toast_manager) + self.settings_page.set_toast_manager(self.toast_manager) + # Configure dashboard with service clients and page references self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.soulseek_client) self.dashboard_page.set_page_references(self.downloads_page, self.sync_page) self.dashboard_page.set_app_start_time(self.app_start_time) + self.dashboard_page.set_toast_manager(self.toast_manager) # Connect download completion signal for session tracking self.downloads_page.download_session_completed.connect( diff --git a/ui/components/__pycache__/toast_manager.cpython-312.pyc b/ui/components/__pycache__/toast_manager.cpython-312.pyc new file mode 100644 index 00000000..fb332748 Binary files /dev/null and b/ui/components/__pycache__/toast_manager.cpython-312.pyc differ diff --git a/ui/components/toast_manager.py b/ui/components/toast_manager.py new file mode 100644 index 00000000..8b636fba --- /dev/null +++ b/ui/components/toast_manager.py @@ -0,0 +1,260 @@ +from PyQt6.QtWidgets import QWidget, QLabel, QHBoxLayout, QVBoxLayout, QGraphicsOpacityEffect +from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, pyqtSignal, QRect +from PyQt6.QtGui import QFont, QPainter, QPaintEvent +import time +from typing import List, Optional +from enum import Enum + +class ToastType(Enum): + SUCCESS = "success" + INFO = "info" + WARNING = "warning" + ERROR = "error" + +class Toast(QWidget): + """Individual toast notification widget""" + closed = pyqtSignal(object) # Emits self when closing + + def __init__(self, message: str, toast_type: ToastType = ToastType.INFO, duration: int = 4000, parent=None): + super().__init__(parent) + self.message = message + self.toast_type = toast_type + self.duration = duration + self.created_time = time.time() + + self.setup_ui() + self.setup_animations() + self.setup_auto_dismiss() + + def setup_ui(self): + """Setup the toast UI""" + self.setFixedHeight(60) + self.setMinimumWidth(300) + self.setMaximumWidth(400) + + # Make the widget click-through for the background but clickable for the content + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + + # Main layout + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(12) + + # Icon label + self.icon_label = QLabel() + self.icon_label.setFont(QFont("Segoe UI", 14)) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setFixedSize(24, 24) + + # Message label + self.message_label = QLabel(self.message) + self.message_label.setFont(QFont("Segoe UI", 10)) + self.message_label.setWordWrap(True) + self.message_label.setAlignment(Qt.AlignmentFlag.AlignVCenter) + + layout.addWidget(self.icon_label) + layout.addWidget(self.message_label, 1) + + # Apply styling based on toast type + self.apply_styling() + + def apply_styling(self): + """Apply styling based on toast type""" + if self.toast_type == ToastType.SUCCESS: + icon = "✅" + accent_color = "#1db954" # Spotify green + bg_color = "rgba(29, 185, 84, 0.15)" + border_color = "rgba(29, 185, 84, 0.3)" + elif self.toast_type == ToastType.ERROR: + icon = "❌" + accent_color = "#f04747" + bg_color = "rgba(240, 71, 71, 0.15)" + border_color = "rgba(240, 71, 71, 0.3)" + elif self.toast_type == ToastType.WARNING: + icon = "⚠️" + accent_color = "#ffa500" + bg_color = "rgba(255, 165, 0, 0.15)" + border_color = "rgba(255, 165, 0, 0.3)" + else: # INFO + icon = "ℹ️" + accent_color = "#5865f2" + bg_color = "rgba(88, 101, 242, 0.15)" + border_color = "rgba(88, 101, 242, 0.3)" + + self.icon_label.setText(icon) + + self.setStyleSheet(f""" + Toast {{ + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(45, 45, 45, 0.95), + stop:1 rgba(35, 35, 35, 0.95)); + border: 1px solid {border_color}; + border-left: 3px solid {accent_color}; + border-radius: 8px; + }} + """) + + self.message_label.setStyleSheet(f""" + color: #ffffff; + background: transparent; + """) + + def setup_animations(self): + """Setup slide-in and fade-out animations""" + # Opacity effect for fade animations + self.opacity_effect = QGraphicsOpacityEffect() + self.setGraphicsEffect(self.opacity_effect) + + # Slide-in animation (from right) + self.slide_animation = QPropertyAnimation(self, b"geometry") + self.slide_animation.setDuration(300) + self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic) + + # Fade-out animation + self.fade_animation = QPropertyAnimation(self.opacity_effect, b"opacity") + self.fade_animation.setDuration(200) + self.fade_animation.setEasingCurve(QEasingCurve.Type.OutQuad) + + # Connect fade animation to close + self.fade_animation.finished.connect(self._on_fade_complete) + + def setup_auto_dismiss(self): + """Setup auto-dismiss timer""" + if self.duration > 0: + self.dismiss_timer = QTimer() + self.dismiss_timer.setSingleShot(True) + self.dismiss_timer.timeout.connect(self.dismiss) + self.dismiss_timer.start(self.duration) + + def show_at_position(self, target_rect: QRect): + """Show the toast with slide-in animation at the specified position""" + # Start position (off-screen to the right) + start_rect = QRect(target_rect.x() + 50, target_rect.y(), target_rect.width(), target_rect.height()) + + # Set initial position and show + self.setGeometry(start_rect) + self.show() + + # Animate to target position + self.slide_animation.setStartValue(start_rect) + self.slide_animation.setEndValue(target_rect) + self.slide_animation.start() + + def dismiss(self): + """Dismiss the toast with fade-out animation""" + if hasattr(self, 'dismiss_timer'): + self.dismiss_timer.stop() + + self.fade_animation.setStartValue(1.0) + self.fade_animation.setEndValue(0.0) + self.fade_animation.start() + + def _on_fade_complete(self): + """Called when fade animation completes""" + self.closed.emit(self) + self.hide() + self.deleteLater() + + def mousePressEvent(self, event): + """Handle click to dismiss""" + if event.button() == Qt.MouseButton.LeftButton: + self.dismiss() + super().mousePressEvent(event) + + +class ToastManager(QWidget): + """Manages multiple toast notifications""" + + def __init__(self, parent=None): + super().__init__(parent) + self.parent_widget = parent + self.active_toasts: List[Toast] = [] + self.toast_spacing = 10 + self.margin_from_edge = 20 + + # Make this widget transparent and non-interactive + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def show_toast(self, message: str, toast_type: ToastType = ToastType.INFO, duration: int = 4000): + """Show a new toast notification""" + toast = Toast(message, toast_type, duration, self.parent_widget) + toast.closed.connect(self._on_toast_closed) + + # Calculate position for this toast + position = self._calculate_toast_position(len(self.active_toasts)) + + # Add to active toasts list + self.active_toasts.append(toast) + + # Show the toast + toast.show_at_position(position) + + # Reposition existing toasts if needed + self._reposition_existing_toasts() + + def _calculate_toast_position(self, index: int) -> QRect: + """Calculate position for a toast at the given index""" + if not self.parent_widget: + return QRect(0, 0, 350, 60) + + parent_rect = self.parent_widget.rect() + toast_height = 60 + toast_width = 350 + + x = parent_rect.width() - toast_width - self.margin_from_edge + y = self.margin_from_edge + (index * (toast_height + self.toast_spacing)) + + return QRect(x, y, toast_width, toast_height) + + def _reposition_existing_toasts(self): + """Reposition existing toasts to make room for new ones""" + for i, toast in enumerate(self.active_toasts[:-1]): # Exclude the newest toast + new_position = self._calculate_toast_position(i) + + # Animate to new position if needed + current_geo = toast.geometry() + if current_geo.y() != new_position.y(): + toast.slide_animation.stop() + toast.slide_animation.setStartValue(current_geo) + toast.slide_animation.setEndValue(new_position) + toast.slide_animation.start() + + def _on_toast_closed(self, toast: Toast): + """Handle when a toast is closed""" + if toast in self.active_toasts: + self.active_toasts.remove(toast) + + # Reposition remaining toasts + for i, remaining_toast in enumerate(self.active_toasts): + new_position = self._calculate_toast_position(i) + current_geo = remaining_toast.geometry() + + if current_geo != new_position: + remaining_toast.slide_animation.stop() + remaining_toast.slide_animation.setStartValue(current_geo) + remaining_toast.slide_animation.setEndValue(new_position) + remaining_toast.slide_animation.start() + + def clear_all_toasts(self): + """Dismiss all active toasts""" + for toast in self.active_toasts.copy(): + toast.dismiss() + + # Convenience methods for different toast types + def success(self, message: str, duration: int = 4000): + """Show a success toast""" + self.show_toast(message, ToastType.SUCCESS, duration) + + def error(self, message: str, duration: int = 6000): + """Show an error toast (longer duration)""" + self.show_toast(message, ToastType.ERROR, duration) + + def warning(self, message: str, duration: int = 5000): + """Show a warning toast""" + self.show_toast(message, ToastType.WARNING, duration) + + def info(self, message: str, duration: int = 4000): + """Show an info toast""" + self.show_toast(message, ToastType.INFO, duration) \ No newline at end of file diff --git a/ui/pages/__pycache__/artists.cpython-312.pyc b/ui/pages/__pycache__/artists.cpython-312.pyc index f209d83e..31ff59e8 100644 Binary files a/ui/pages/__pycache__/artists.cpython-312.pyc and b/ui/pages/__pycache__/artists.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/dashboard.cpython-312.pyc b/ui/pages/__pycache__/dashboard.cpython-312.pyc index 8a2ad8e7..1a34e43c 100644 Binary files a/ui/pages/__pycache__/dashboard.cpython-312.pyc and b/ui/pages/__pycache__/dashboard.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/downloads.cpython-312.pyc b/ui/pages/__pycache__/downloads.cpython-312.pyc index aab23efd..28457d03 100644 Binary files a/ui/pages/__pycache__/downloads.cpython-312.pyc and b/ui/pages/__pycache__/downloads.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/settings.cpython-312.pyc b/ui/pages/__pycache__/settings.cpython-312.pyc index 2873d6c8..bbcbd768 100644 Binary files a/ui/pages/__pycache__/settings.cpython-312.pyc and b/ui/pages/__pycache__/settings.cpython-312.pyc differ diff --git a/ui/pages/__pycache__/sync.cpython-312.pyc b/ui/pages/__pycache__/sync.cpython-312.pyc index d587bcb5..67f3a472 100644 Binary files a/ui/pages/__pycache__/sync.cpython-312.pyc and b/ui/pages/__pycache__/sync.cpython-312.pyc differ diff --git a/ui/pages/artists.py b/ui/pages/artists.py index e766a782..3ea8e8dc 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -2378,6 +2378,10 @@ class ArtistsPage(QWidget): self.setup_ui() self.setup_clients() + def set_toast_manager(self, toast_manager): + """Set the toast manager for showing notifications""" + self.toast_manager = toast_manager + def setup_clients(self): """Initialize client connections""" try: @@ -3004,6 +3008,10 @@ class ArtistsPage(QWidget): existing_session = self.active_album_sessions[album.id] existing_modal = existing_session.get('modal') + # Show toast notification for already active session + if hasattr(self, 'toast_manager') and self.toast_manager: + self.toast_manager.info(f"Downloads already in progress for '{album.name}'") + # Check if the modal still exists and is valid if existing_modal and not existing_modal.isVisible(): try: diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index 6294896b..5d766050 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -1206,6 +1206,10 @@ class DashboardPage(QWidget): """Called from main window to provide app start time for uptime calculation""" self.data_provider.set_app_start_time(start_time) + def set_toast_manager(self, toast_manager): + """Set the toast manager for showing notifications""" + self.toast_manager = toast_manager + def setup_ui(self): self.setStyleSheet(""" DashboardPage { @@ -1615,7 +1619,11 @@ class DashboardPage(QWidget): pass def add_activity_item(self, icon: str, title: str, subtitle: str, time_ago: str = "Now"): - """Add new activity item to the feed""" + """Add new activity item to the feed and potentially show a toast""" + # Show toast for immediate user actions (if toast manager is available) + if hasattr(self, 'toast_manager') and self.toast_manager: + self._maybe_show_toast(icon, title, subtitle) + # Remove placeholder if it exists if self.has_placeholder: # Clear the entire layout @@ -1642,6 +1650,44 @@ class DashboardPage(QWidget): if item.widget(): item.widget().deleteLater() + def _maybe_show_toast(self, icon: str, title: str, subtitle: str): + """Determine if this activity should show a toast notification""" + from ui.components.toast_manager import ToastType + + # Success activities that deserve toasts + if icon == "✅" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]): + self.toast_manager.success(f"{title}: {subtitle}") + return + + if icon == "📥" and "Download Started" in title: + self.toast_manager.success(f"{subtitle}") + return + + if icon == "🔍" and "Search Complete" in title: + self.toast_manager.info(f"{subtitle}") + return + + # Error activities that need immediate attention + if icon == "❌": + # Skip routine background errors + if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]): + return + + # Show errors for user-initiated actions + if any(keyword in title.lower() for keyword in ["download failed", "sync failed", "search failed"]): + self.toast_manager.error(f"{title}: {subtitle}") + return + + # Warning activities + if icon == "⚠️": + self.toast_manager.warning(f"{title}: {subtitle}") + return + + # Info activities for searches and connections + if icon == "🔍" and "Search Started" in title: + self.toast_manager.info(f"{subtitle}") + return + def closeEvent(self, event): """Clean up threads when dashboard is closed""" self.cleanup_threads() diff --git a/ui/pages/downloads.py b/ui/pages/downloads.py index ea09dced..435c917a 100644 --- a/ui/pages/downloads.py +++ b/ui/pages/downloads.py @@ -4922,6 +4922,10 @@ class DownloadsPage(QWidget): self.api_cleanup_finished.connect(self._handle_api_cleanup_completion) self.setup_ui() + + def set_toast_manager(self, toast_manager): + """Set the toast manager for showing notifications""" + self.toast_manager = toast_manager diff --git a/ui/pages/settings.py b/ui/pages/settings.py index e8eb911b..2d9d8682 100644 --- a/ui/pages/settings.py +++ b/ui/pages/settings.py @@ -449,6 +449,10 @@ class SettingsPage(QWidget): self.setup_ui() self.load_config_values() + def set_toast_manager(self, toast_manager): + """Set the toast manager for showing notifications""" + self.toast_manager = toast_manager + def on_test_completed(self, service, success, message): """Handle test completion from background thread""" # Re-enable the test button diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 1e5eef0d..a1abd0f8 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -1973,6 +1973,10 @@ class SyncPage(QWidget): self.show_initial_state() self.playlists_loaded = False + def set_toast_manager(self, toast_manager): + """Set the toast manager for showing notifications""" + self.toast_manager = toast_manager + def _update_and_save_sync_status(self, playlist_id, result, snapshot_id): """Updates the sync status for a given playlist and saves to file."""