toast
This commit is contained in:
parent
3b0adc3e4f
commit
091f3b3833
13 changed files with 338 additions and 1 deletions
11
main.py
11
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(
|
||||
|
|
|
|||
BIN
ui/components/__pycache__/toast_manager.cpython-312.pyc
Normal file
BIN
ui/components/__pycache__/toast_manager.cpython-312.pyc
Normal file
Binary file not shown.
260
ui/components/toast_manager.py
Normal file
260
ui/components/toast_manager.py
Normal file
|
|
@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue