from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QSizePolicy, QSpacerItem, QSlider, QProgressBar, QApplication) from PyQt6.QtCore import Qt, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect, QTimer, pyqtProperty from PyQt6.QtGui import QFont, QPalette, QIcon, QPixmap, QPainter, QFontMetrics, QColor, QLinearGradient from utils.logging_config import get_logger class ScrollingLabel(QLabel): """A label that smoothly scrolls text horizontally when it's too long to fit""" def __init__(self, text="", parent=None): super().__init__(parent) self.full_text = text self.scroll_offset = 0 self.text_width = 0 self.should_scroll = False self.is_scrolling = False self.scroll_speed = 30 # pixels per second # Animation timer self.scroll_timer = QTimer() self.scroll_timer.timeout.connect(self.update_scroll) # Pause timer for smooth start/stop self.pause_timer = QTimer() self.pause_timer.setSingleShot(True) self.pause_timer.timeout.connect(self.start_scroll_animation) # Set initial properties self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.update_text_metrics() def setText(self, text): """Override setText to handle scroll calculations""" self.full_text = text self.scroll_offset = 0 self.update_text_metrics() super().setText(text) def update_text_metrics(self): """Calculate if text needs scrolling and start animation if needed""" if not self.full_text: self.should_scroll = False self.stop_scrolling() return font_metrics = QFontMetrics(self.font()) self.text_width = font_metrics.horizontalAdvance(self.full_text) available_width = self.width() - 20 # Account for padding self.should_scroll = self.text_width > available_width and available_width > 0 if self.should_scroll and not self.is_scrolling: # Start scrolling after a pause self.pause_timer.start(1500) # 1.5 second pause before scrolling elif not self.should_scroll: self.stop_scrolling() def start_scroll_animation(self): """Start the continuous scrolling animation""" if self.should_scroll and not self.is_scrolling: self.is_scrolling = True self.scroll_timer.start(50) # Update every 50ms for smooth animation def stop_scrolling(self): """Stop scrolling and reset position""" self.scroll_timer.stop() self.pause_timer.stop() self.is_scrolling = False self.scroll_offset = 0 self.update() def update_scroll(self): """Update scroll position for animation""" if not self.should_scroll: self.stop_scrolling() return available_width = self.width() - 20 max_scroll = self.text_width - available_width + 30 # Extra padding at end # Move scroll position self.scroll_offset += 2 # 2 pixels per frame # Reset when we've scrolled past the end if self.scroll_offset > max_scroll: self.scroll_offset = -50 # Start from off-screen left self.update() def paintEvent(self, event): """Custom paint event to draw scrolling text""" if not self.should_scroll or not self.is_scrolling: # Use default painting for non-scrolling text super().paintEvent(event) return painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) # Set font and color from stylesheet painter.setFont(self.font()) # Get text color from current style painter.setPen(self.palette().color(QPalette.ColorRole.WindowText)) # Draw text at scroll offset position text_rect = self.rect() text_rect.adjust(10, 0, -10, 0) # Account for padding painter.drawText(text_rect.x() - self.scroll_offset, text_rect.y(), text_rect.width() + self.text_width, text_rect.height(), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.full_text) def resizeEvent(self, event): """Handle resize to recalculate scrolling needs""" super().resizeEvent(event) self.update_text_metrics() def enterEvent(self, event): """Start scrolling on hover""" super().enterEvent(event) if self.should_scroll and not self.is_scrolling: self.start_scroll_animation() def leaveEvent(self, event): """Optionally stop scrolling when mouse leaves (can be customized)""" super().leaveEvent(event) # Note: We continue scrolling even after mouse leaves for better UX # You can uncomment the line below if you want it to stop on mouse leave # self.stop_scrolling() class SidebarButton(QPushButton): def __init__(self, text: str, icon_text: str = "", parent=None): super().__init__(parent) self.text = text self.icon_text = icon_text self.is_active = False self.setup_ui() def setup_ui(self): self.setFixedHeight(52) self.setFixedWidth(216) # Adjusted for new sidebar width self.setCursor(Qt.CursorShape.PointingHandCursor) layout = QHBoxLayout(self) layout.setContentsMargins(18, 0, 18, 0) layout.setSpacing(16) # Icon label with better styling self.icon_label = QLabel(self.icon_text) self.icon_label.setFixedSize(28, 28) self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.icon_label.setStyleSheet(""" QLabel { color: rgba(255, 255, 255, 0.7); font-size: 16px; font-weight: 600; border-radius: 14px; background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(255, 255, 255, 0.08), stop: 1 rgba(255, 255, 255, 0.04)); border: 1px solid rgba(255, 255, 255, 0.05); } """) # Text label with improved typography self.text_label = QLabel(self.text) self.text_label.setFont(QFont("SF Pro Text", 12, QFont.Weight.Medium)) layout.addWidget(self.icon_label) layout.addWidget(self.text_label) layout.addStretch() self.update_style() def set_active(self, active: bool): self.is_active = active self.update_style() def update_style(self): if self.is_active: self.setStyleSheet(""" SidebarButton { background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(29, 185, 84, 0.18), stop: 0.5 rgba(29, 185, 84, 0.12), stop: 1 rgba(29, 185, 84, 0.08)); border-left: 3px solid #1ed760; border-radius: 16px; text-align: left; padding: 0px; border: 1px solid rgba(29, 185, 84, 0.2); } SidebarButton:hover { background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(29, 185, 84, 0.25), stop: 0.5 rgba(29, 185, 84, 0.18), stop: 1 rgba(29, 185, 84, 0.12)); border: 1px solid rgba(29, 185, 84, 0.3); } """) self.text_label.setStyleSheet(""" color: #1ed760; font-weight: 600; background: transparent; letter-spacing: 0.1px; """) self.icon_label.setStyleSheet(""" QLabel { color: #1ed760; font-size: 16px; font-weight: 700; border-radius: 14px; background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(29, 185, 84, 0.25), stop: 1 rgba(30, 215, 96, 0.2)); border: 1px solid rgba(29, 185, 84, 0.3); } """) else: self.setStyleSheet(""" SidebarButton { background: transparent; border: none; border-radius: 16px; text-align: left; padding: 0px; } SidebarButton:hover { background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(255, 255, 255, 0.06), stop: 1 rgba(255, 255, 255, 0.03)); border-left: 2px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.08); } """) self.text_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.8); background: transparent; letter-spacing: 0.1px; """) self.icon_label.setStyleSheet(""" QLabel { color: rgba(255, 255, 255, 0.7); font-size: 16px; font-weight: 600; border-radius: 14px; background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(255, 255, 255, 0.08), stop: 1 rgba(255, 255, 255, 0.04)); border: 1px solid rgba(255, 255, 255, 0.05); } """) class CryptoDonationWidget(QWidget): """Widget for displaying crypto donation addresses with collapsible section""" def __init__(self, parent=None): super().__init__(parent) self.addresses_visible = False self.setup_ui() def setup_ui(self): self.setStyleSheet(""" CryptoDonationWidget { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 transparent, stop: 0.3 rgba(255, 255, 255, 0.02), stop: 1 rgba(255, 255, 255, 0.04)); border-top: 1px solid rgba(255, 255, 255, 0.08); border-bottom-right-radius: 12px; } """) layout = QVBoxLayout(self) layout.setContentsMargins(0, 15, 0, 15) layout.setSpacing(8) # Header with title and toggle button header_layout = QHBoxLayout() header_layout.setContentsMargins(20, 0, 20, 0) header_layout.setSpacing(8) # Donation title donation_title = QLabel("Support Development") donation_title.setFont(QFont("SF Pro Text", 10, QFont.Weight.Bold)) donation_title.setMinimumHeight(16) donation_title.setStyleSheet(""" color: rgba(255, 255, 255, 0.9); margin-bottom: 5px; letter-spacing: 0.2px; font-weight: 600; """) # Toggle button self.toggle_btn = QPushButton("Show") self.toggle_btn.setFixedSize(40, 20) self.toggle_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.toggle_btn.setStyleSheet(""" QPushButton { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 10px; color: rgba(255, 255, 255, 0.7); font-size: 8px; font-weight: 500; } QPushButton:hover { background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.9); } """) self.toggle_btn.clicked.connect(self.toggle_addresses) header_layout.addWidget(donation_title) header_layout.addStretch() header_layout.addWidget(self.toggle_btn) layout.addLayout(header_layout) # Container for donation options (initially hidden) self.addresses_container = QWidget() self.addresses_layout = QVBoxLayout(self.addresses_container) self.addresses_layout.setContentsMargins(0, 0, 0, 0) self.addresses_layout.setSpacing(8) # Ko-fi option (first item) kofi_item = self.create_kofi_item() self.addresses_layout.addWidget(kofi_item) # Crypto addresses crypto_addresses = [ ("BTC", "Bitcoin", "3JVWrRSkozAQSmw5DXYVxYKsM9bndPTqdS"), ("ETH", "Ethereum", "0x343fC48c2cd1C6332b0df9a58F86e6520a026AC5") ] for symbol, name, address in crypto_addresses: crypto_item = self.create_crypto_item(symbol, name, address) self.addresses_layout.addWidget(crypto_item) # Initially hide the addresses self.addresses_container.hide() layout.addWidget(self.addresses_container) def toggle_addresses(self): """Toggle the visibility of crypto addresses""" self.addresses_visible = not self.addresses_visible if self.addresses_visible: self.addresses_container.show() self.toggle_btn.setText("Hide") else: self.addresses_container.hide() self.toggle_btn.setText("Show") def create_crypto_item(self, symbol: str, name: str, address: str): """Create a clickable crypto donation item""" item = QFrame() item.setFixedHeight(32) item.setCursor(Qt.CursorShape.PointingHandCursor) item.setStyleSheet(""" QFrame { background: transparent; border-radius: 8px; margin: 0 12px; } QFrame:hover { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); } """) layout = QHBoxLayout(item) layout.setContentsMargins(12, 4, 12, 4) layout.setSpacing(6) # Crypto name name_label = QLabel(name) name_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) name_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.8); font-weight: 500; """) # Address (truncated) address_short = f"{address[:6]}...{address[-4:]}" address_label = QLabel(address_short) address_label.setFont(QFont("SF Pro Text", 8)) address_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.5); font-family: 'Courier New', monospace; """) layout.addWidget(name_label) layout.addStretch() layout.addWidget(address_label) # Store full address for copying item.full_address = address item.crypto_name = name item.mousePressEvent = lambda event: self.copy_address(address, name) return item def copy_address(self, address: str, crypto_name: str): """Copy crypto address to clipboard""" clipboard = QApplication.clipboard() clipboard.setText(address) # Brief visual feedback (could add a tooltip or status message here) print(f"Copied {crypto_name} address to clipboard: {address}") def create_kofi_item(self): """Create a clickable Ko-fi donation item styled like crypto items""" item = QFrame() item.setFixedHeight(32) item.setCursor(Qt.CursorShape.PointingHandCursor) item.setStyleSheet(""" QFrame { background: transparent; border-radius: 8px; margin: 0 12px; } QFrame:hover { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); } """) layout = QHBoxLayout(item) layout.setContentsMargins(12, 4, 12, 4) layout.setSpacing(6) # Ko-fi name name_label = QLabel("Ko-fi") name_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) name_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.8); font-weight: 500; """) # External link indicator (instead of address) link_label = QLabel("Click to open") link_label.setFont(QFont("SF Pro Text", 8)) link_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.5); font-style: italic; """) layout.addWidget(name_label) layout.addStretch() layout.addWidget(link_label) # Connect click event to open Ko-fi link item.mousePressEvent = lambda event: self.open_kofi_link() return item def open_kofi_link(self): """Open Ko-fi link in the user's default web browser""" import webbrowser kofi_url = "https://ko-fi.com/boulderbadgedad" webbrowser.open(kofi_url) print(f"Opening Ko-fi link: {kofi_url}") class StatusIndicator(QWidget): def __init__(self, service_name: str, parent=None): super().__init__(parent) self.service_name = service_name self.is_connected = False self.setup_ui() def setup_ui(self): self.setFixedHeight(38) # Slightly taller for better proportions layout = QHBoxLayout(self) layout.setContentsMargins(20, 8, 20, 8) layout.setSpacing(14) # Status dot with more elegant design self.status_dot = QLabel("●") self.status_dot.setFixedSize(18, 18) self.status_dot.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_dot.setStyleSheet(""" QLabel { border-radius: 9px; font-size: 10px; font-weight: 700; border: 1px solid rgba(255, 255, 255, 0.1); } """) # Service name with better typography self.service_label = QLabel(self.service_name) self.service_label.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) self.service_label.setMinimumWidth(85) layout.addWidget(self.status_dot) layout.addWidget(self.service_label) layout.addStretch() self.update_status(False) def update_status(self, connected: bool): self.is_connected = connected if connected: self.status_dot.setStyleSheet(""" QLabel { color: #1ed760; background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(29, 185, 84, 0.2), stop: 1 rgba(30, 215, 96, 0.15)); border-radius: 9px; font-size: 10px; font-weight: 700; border: 1px solid rgba(29, 185, 84, 0.3); } """) self.service_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.95); font-weight: 500; letter-spacing: 0.1px; """) else: self.status_dot.setStyleSheet(""" QLabel { color: #ff6b6b; background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 rgba(255, 107, 107, 0.15), stop: 1 rgba(255, 107, 107, 0.1)); border-radius: 9px; font-size: 10px; font-weight: 700; border: 1px solid rgba(255, 107, 107, 0.2); } """) self.service_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.5); font-weight: 400; letter-spacing: 0.1px; """) def update_name(self, new_name: str): """Update the service name displayed in the status indicator""" self.service_name = new_name self.service_label.setText(new_name) class LoadingAnimation(QWidget): """Thin horizontal loading animation for media player with dual-mode capability""" def __init__(self, parent=None): super().__init__(parent) self.setFixedHeight(12) # Increased height for text overlay self._progress = 0.0 self._is_active = False self._mode = "indefinite" # "indefinite" or "determinate" self._determinate_progress = 0.0 # 0-100% for determinate mode # Animation setup for indefinite mode self.animation = QPropertyAnimation(self, b"progress") self.animation.setDuration(1200) # 1.2 second cycle self.animation.setStartValue(0.0) self.animation.setEndValue(1.0) self.animation.setLoopCount(-1) # Infinite loop self.animation.setEasingCurve(QEasingCurve.Type.InOutSine) # Progress value animation for smooth transitions in determinate mode self.progress_animation = QPropertyAnimation(self, b"determinate_progress") self.progress_animation.setDuration(300) # Smooth 300ms transitions self.progress_animation.setEasingCurve(QEasingCurve.Type.OutCubic) # Completion glow effect self._glow_opacity = 0.0 self.glow_animation = QPropertyAnimation(self, b"glow_opacity") self.glow_animation.setDuration(800) # Slower glow pulse self.glow_animation.setStartValue(0.0) self.glow_animation.setEndValue(1.0) self.glow_animation.setLoopCount(3) # Pulse 3 times self.glow_animation.setEasingCurve(QEasingCurve.Type.InOutSine) self.hide() # Start hidden @pyqtProperty(float) def progress(self): return self._progress @progress.setter def progress(self, value): self._progress = value self.update() @pyqtProperty(float) def determinate_progress(self): return self._determinate_progress @determinate_progress.setter def determinate_progress(self, value): self._determinate_progress = value self.update() @pyqtProperty(float) def glow_opacity(self): return self._glow_opacity @glow_opacity.setter def glow_opacity(self, value): self._glow_opacity = value self.update() def start_animation(self): """Start the indefinite loading animation""" self._is_active = True self._mode = "indefinite" self.show() self.animation.start() def set_progress(self, percentage): """Set determinate progress (0-100%) with smooth animation""" if not self._is_active: self._is_active = True self.show() # Switch to determinate mode if self._mode == "indefinite": self._mode = "determinate" self.animation.stop() # Stop indefinite animation # Animate to new progress value self.progress_animation.setStartValue(self._determinate_progress) self.progress_animation.setEndValue(percentage) self.progress_animation.start() # Trigger completion glow effect when reaching 100% if percentage >= 100 and self._determinate_progress < 100: self.glow_animation.start() def stop_animation(self): """Stop the loading animation""" self._is_active = False self._mode = "indefinite" self.animation.stop() self.progress_animation.stop() self.glow_animation.stop() self.hide() self._progress = 0.0 self._determinate_progress = 0.0 self._glow_opacity = 0.0 self.update() def paintEvent(self, event): """Custom paint event for dual-mode animation with text overlay""" if not self._is_active: return painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) width = self.width() height = self.height() progress_bar_height = 4 # Bottom 4px for progress bar text_height = height - progress_bar_height # Top area for text # Background for progress bar area progress_rect = self.rect() progress_rect.setTop(text_height) painter.fillRect(progress_rect, QColor(40, 40, 40)) if self._mode == "indefinite": # Indefinite mode: animated gradient wave gradient_width = width * 0.3 # 30% of total width center_x = self._progress * width for i in range(int(gradient_width)): alpha = max(0, 255 - (abs(i - gradient_width/2) * 8)) color = QColor(29, 185, 84, int(alpha)) # Spotify green with fade x = int(center_x - gradient_width/2 + i) if 0 <= x < width: painter.fillRect(x, text_height, 1, progress_bar_height, color) else: # determinate mode # Determinate mode: progress bar with percentage progress_width = (self._determinate_progress / 100.0) * width # Progress bar with gradient if progress_width > 0: progress_fill_rect = QRect(0, text_height, int(progress_width), progress_bar_height) # Create subtle gradient for progress bar gradient = QLinearGradient(0, text_height, progress_width, text_height) gradient.setColorAt(0, QColor(29, 185, 84)) # Spotify green gradient.setColorAt(1, QColor(30, 215, 96)) # Lighter green painter.fillRect(progress_fill_rect, gradient) # Add animated glow effect during completion if self._glow_opacity > 0: glow_alpha = int(120 * self._glow_opacity) # Max alpha of 120 glow_color = QColor(29, 185, 84, glow_alpha) # Expand glow slightly beyond progress bar for effect glow_rect = QRect(0, text_height - 1, width, progress_bar_height + 2) painter.fillRect(glow_rect, glow_color) # Percentage text overlay (elegant, small font) if text_height > 0 and self._determinate_progress > 0: font = QFont("Segoe UI", 7, QFont.Weight.Medium) # Small, elegant font painter.setFont(font) painter.setPen(QColor(180, 180, 180)) # Light gray text percentage_text = f"{int(self._determinate_progress)}%" text_rect = QRect(0, 0, width, text_height) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, percentage_text) class MediaPlayer(QWidget): # Signals for media control play_pause_requested = pyqtSignal() stop_requested = pyqtSignal() volume_changed = pyqtSignal(float) # Volume as percentage (0.0 to 1.0) def __init__(self, parent=None): super().__init__(parent) self.is_playing = False self.is_expanded = False self.current_track = None self.setup_ui() def setup_ui(self): self.setFixedHeight(85) # More space for better proportions self.setStyleSheet(""" MediaPlayer { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 rgba(26, 26, 26, 0.95), stop: 0.5 rgba(18, 18, 18, 0.98), stop: 1 rgba(12, 12, 12, 1.0)); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 12px; margin: 8px 10px; } MediaPlayer:hover { border: 1px solid rgba(29, 185, 84, 0.2); background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 rgba(29, 185, 84, 0.08), stop: 0.5 rgba(26, 26, 26, 0.95), stop: 1 rgba(18, 18, 18, 1.0)); } """) layout = QVBoxLayout(self) layout.setContentsMargins(18, 12, 18, 12) layout.setSpacing(12) # Loading animation at the top self.loading_animation = LoadingAnimation() layout.addWidget(self.loading_animation) # Always visible header with basic controls self.header = self.create_header() layout.addWidget(self.header) # Expandable content (hidden when collapsed) self.expanded_content = self.create_expanded_content() self.expanded_content.setVisible(False) layout.addWidget(self.expanded_content) # No track message (shown when no music) self.no_track_label = QLabel("Start playing music to see controls") self.no_track_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.no_track_label.setStyleSheet(""" QLabel { color: #6a6a6a; font-size: 12px; font-weight: 400; padding: 20px 16px; background: transparent; letter-spacing: 0.2px; font-family: 'Spotify Circular', -apple-system, sans-serif; line-height: 1.4; } """) layout.addWidget(self.no_track_label) def create_header(self): header = QWidget() main_layout = QVBoxLayout(header) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(8) # Top row: Track info and play button top_row = QHBoxLayout() top_row.setContentsMargins(0, 0, 0, 0) top_row.setSpacing(14) # Track info (expandable on click) - now with scrolling for long titles self.track_info = ScrollingLabel("No track") self.track_info.setStyleSheet(""" ScrollingLabel { color: #ffffff; font-size: 14px; font-weight: 700; background: transparent; font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; letter-spacing: -0.3px; padding: 2px 0px; line-height: 1.2; } ScrollingLabel:hover { color: #1ed760; text-decoration: underline; } """) self.track_info.setCursor(Qt.CursorShape.PointingHandCursor) self.track_info.mousePressEvent = self.toggle_expansion # Play/pause button - more Spotify-like self.play_pause_btn = QPushButton("β–·") self.play_pause_btn.setFixedSize(40, 40) self.play_pause_btn.setStyleSheet(""" QPushButton { background: #1ed760; border: none; border-radius: 20px; color: #000000; font-size: 16px; font-weight: 900; font-family: 'Arial', sans-serif; } QPushButton:hover { background: #1fdf64; } QPushButton:pressed { background: #1ca851; } QPushButton:disabled { background: #535353; color: #b3b3b3; } """) self.play_pause_btn.clicked.connect(self.on_play_pause_clicked) self.play_pause_btn.setEnabled(False) top_row.addWidget(self.track_info) top_row.addStretch() top_row.addWidget(self.play_pause_btn) # Bottom row: Artist info (always visible in collapsed mode) self.artist_info = QLabel("Unknown Artist") self.artist_info.setStyleSheet(""" QLabel { color: #b3b3b3; font-size: 11px; font-weight: 400; background: transparent; font-family: 'Spotify Circular', -apple-system, BlinkMacSystemFont, sans-serif; letter-spacing: 0.1px; margin-top: 1px; } """) main_layout.addLayout(top_row) main_layout.addWidget(self.artist_info) return header def create_expanded_content(self): content = QWidget() layout = QVBoxLayout(content) layout.setContentsMargins(0, 2, 0, 0) layout.setSpacing(4) # Album info self.album_label = QLabel("Unknown Album") self.album_label.setStyleSheet(""" QLabel { color: #a7a7a7; font-size: 11px; font-weight: 400; background: transparent; font-family: 'Spotify Circular', -apple-system, BlinkMacSystemFont, sans-serif; letter-spacing: 0.1px; } """) layout.addWidget(self.album_label) # Control buttons - more Spotify-like controls_layout = QHBoxLayout() controls_layout.setContentsMargins(0, 1, 0, 0) controls_layout.setSpacing(6) # Volume control (Spotify style - more prominent) volume_layout = QHBoxLayout() volume_layout.setSpacing(10) volume_icon = QLabel("πŸ”Š") volume_icon.setStyleSheet(""" QLabel { color: #b3b3b3; font-size: 13px; font-weight: 400; padding: 0px; } """) self.volume_slider = QSlider(Qt.Orientation.Horizontal) self.volume_slider.setRange(0, 100) self.volume_slider.setValue(70) # Default 70% volume self.volume_slider.setFixedWidth(80) self.volume_slider.setFixedHeight(20) self.volume_slider.setStyleSheet(""" QSlider::groove:horizontal { border: none; height: 3px; background: #4f4f4f; border-radius: 1px; } QSlider::handle:horizontal { background: #ffffff; border: none; width: 12px; height: 12px; border-radius: 6px; margin: -4px 0; } QSlider::handle:horizontal:hover { background: #1ed760; } QSlider::sub-page:horizontal { background: #1ed760; border-radius: 1px; } """) self.volume_slider.valueChanged.connect(self.on_volume_changed) # Stop button - more visible Spotify style self.stop_btn = QPushButton("⏹") self.stop_btn.setFixedSize(32, 32) self.stop_btn.setStyleSheet(""" QPushButton { background: rgba(255, 255, 255, 0.08); border: 1px solid #b3b3b3; border-radius: 16px; color: #ffffff; font-size: 12px; font-weight: 500; } QPushButton:hover { background: rgba(255, 255, 255, 0.15); border: 1px solid #ffffff; color: #ffffff; } QPushButton:pressed { background: rgba(255, 255, 255, 0.25); } QPushButton:disabled { background: transparent; border: 1px solid #2a2a2a; color: #535353; } """) self.stop_btn.clicked.connect(self.on_stop_clicked) self.stop_btn.setEnabled(False) volume_layout.addWidget(volume_icon) volume_layout.addWidget(self.volume_slider) controls_layout.addLayout(volume_layout) controls_layout.addStretch() controls_layout.addWidget(self.stop_btn) layout.addLayout(controls_layout) return content def toggle_expansion(self, event=None): """Toggle between collapsed and expanded view""" if not self.current_track: return self.is_expanded = not self.is_expanded if self.is_expanded: self.setFixedHeight(145) # More space for the new layout self.expanded_content.setVisible(True) self.no_track_label.setVisible(False) else: self.setFixedHeight(85) # Match the updated collapsed height self.expanded_content.setVisible(False) def set_track_info(self, track_result): """Update the media player with new track information""" self.current_track = track_result # Update track name track_name = getattr(track_result, 'title', None) or getattr(track_result, 'filename', 'Unknown Track') if hasattr(track_result, 'filename'): # Clean up filename for display import os track_name = os.path.splitext(os.path.basename(track_result.filename))[0] self.track_info.setText(track_name) # Update artist and album info artist = getattr(track_result, 'artist', 'Unknown Artist') album = getattr(track_result, 'album', 'Unknown Album') # Update the separate artist and album labels self.artist_info.setText(artist) self.album_label.setText(album) # Enable controls self.play_pause_btn.setEnabled(True) self.stop_btn.setEnabled(True) # Set to playing state (show pause button since track just started) self.set_playing_state(True) # Hide loading animation now that track is ready self.hide_loading() # Hide no track message and show player self.no_track_label.setVisible(False) # Auto-expand when new track starts if not self.is_expanded: self.toggle_expansion() def set_playing_state(self, playing): """Update play/pause button state""" self.is_playing = playing if playing: self.play_pause_btn.setText("⏸︎") # Start scrolling animation when playing if self.track_info.should_scroll and not self.track_info.is_scrolling: self.track_info.start_scroll_animation() else: self.play_pause_btn.setText("β–·") # Optionally stop scrolling when paused (can be customized) # self.track_info.stop_scrolling() def clear_track(self): """Clear current track and reset to no track state""" self.current_track = None self.is_playing = False # Stop any animations self.track_info.stop_scrolling() self.hide_loading() # Update UI self.track_info.setText("No track") self.artist_info.setText("Unknown Artist") self.album_label.setText("Unknown Album") self.play_pause_btn.setText("β–·") self.play_pause_btn.setEnabled(False) self.stop_btn.setEnabled(False) # Show no track message self.no_track_label.setVisible(True) # Collapse view if self.is_expanded: self.toggle_expansion() def on_play_pause_clicked(self): """Handle play/pause button click""" self.play_pause_requested.emit() def on_stop_clicked(self): """Handle stop button click""" self.stop_requested.emit() def on_volume_changed(self, value): """Handle volume slider change""" volume = value / 100.0 # Convert to 0.0-1.0 self.volume_changed.emit(volume) def show_loading(self): """Show and start the loading animation""" self.loading_animation.start_animation() def hide_loading(self): """Hide and stop the loading animation""" self.loading_animation.stop_animation() def set_loading_progress(self, percentage): """Set loading progress percentage (0-100)""" self.loading_animation.set_progress(percentage) class ModernSidebar(QWidget): page_changed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.current_page = "dashboard" self.buttons = {} self.setup_ui() def setup_ui(self): self.setFixedWidth(240) # Slightly wider for better proportions self.setStyleSheet(""" ModernSidebar { background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 #0d1117, stop: 0.3 #121212, stop: 1 #0a0a0a); border-right: 1px solid rgba(29, 185, 84, 0.1); border-top-right-radius: 12px; border-bottom-right-radius: 12px; } """) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Header header = self.create_header() layout.addWidget(header) # Navigation buttons nav_section = self.create_navigation() layout.addWidget(nav_section) # Spacer layout.addItem(QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) # Media Player section self.media_player = MediaPlayer() layout.addWidget(self.media_player) # Small spacer between media player and crypto layout.addItem(QSpacerItem(20, 8, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) # Crypto Donation section crypto_section = CryptoDonationWidget() layout.addWidget(crypto_section) # Version info section version_section = self.create_version_section() layout.addWidget(version_section) # Small spacer between version and status layout.addItem(QSpacerItem(20, 8, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)) # Status section status_section = self.create_status_section() layout.addWidget(status_section) def create_header(self): header = QWidget() header.setFixedHeight(95) header.setStyleSheet(""" QWidget { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 rgba(29, 185, 84, 0.08), stop: 0.4 rgba(29, 185, 84, 0.03), stop: 1 transparent); border-bottom: 1px solid rgba(29, 185, 84, 0.15); border-top-right-radius: 12px; } """) layout = QVBoxLayout(header) layout.setContentsMargins(24, 24, 24, 20) layout.setSpacing(4) # App name with gradient text effect app_name = QLabel("SoulSync") app_name.setFont(QFont("SF Pro Display", 20, QFont.Weight.Bold)) app_name.setStyleSheet(""" color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #ffffff, stop: 0.6 #1ed760, stop: 1 #1db954); letter-spacing: -0.8px; font-weight: 700; """) # Subtitle with better typography subtitle = QLabel("Music Sync & Manager") subtitle.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) subtitle.setStyleSheet(""" color: rgba(255, 255, 255, 0.65); letter-spacing: 0.2px; font-weight: 500; margin-top: 2px; """) layout.addWidget(app_name) layout.addWidget(subtitle) return header def create_navigation(self): nav_widget = QWidget() nav_widget.setStyleSheet(""" QWidget { background: transparent; border-radius: 12px; } """) layout = QVBoxLayout(nav_widget) layout.setContentsMargins(12, 25, 12, 25) layout.setSpacing(8) # Navigation buttons nav_items = [ ("dashboard", "Dashboard", "πŸ“Š"), ("sync", "Sync", "πŸ”„"), ("downloads", "Search", "πŸ“₯"), ("artists", "Artists", "🎡"), ("settings", "Settings", "βš™οΈ") ] for page_id, title, icon in nav_items: button = SidebarButton(title, icon) button.clicked.connect(lambda checked, pid=page_id: self.change_page(pid)) self.buttons[page_id] = button layout.addWidget(button) # Set dashboard as active by default self.buttons["dashboard"].set_active(True) return nav_widget def create_version_section(self): version_widget = QWidget() version_widget.setFixedHeight(45) version_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 transparent, stop: 0.3 rgba(255, 255, 255, 0.02), stop: 1 rgba(255, 255, 255, 0.04)); border-top: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; margin: 0 10px; } """) layout = QVBoxLayout(version_widget) layout.setContentsMargins(20, 12, 20, 12) layout.setSpacing(0) # Version button (clickable) self.version_button = QPushButton("v.0.6") self.version_button.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium)) self.version_button.setCursor(Qt.CursorShape.PointingHandCursor) self.version_button.setStyleSheet(""" QPushButton { color: rgba(255, 255, 255, 0.6); letter-spacing: 0.1px; font-weight: 500; background: transparent; border: none; padding: 2px 8px; border-radius: 4px; } QPushButton:hover { color: #1ed760; background: rgba(29, 185, 84, 0.1); border: 1px solid rgba(29, 185, 84, 0.2); } QPushButton:pressed { background: rgba(29, 185, 84, 0.15); } """) self.version_button.clicked.connect(self.show_version_info) layout.addWidget(self.version_button) return version_widget def create_status_section(self): status_widget = QWidget() status_widget.setFixedHeight(150) # Slightly taller for better proportions status_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 transparent, stop: 0.3 rgba(255, 255, 255, 0.02), stop: 1 rgba(255, 255, 255, 0.04)); border-top: 1px solid rgba(255, 255, 255, 0.08); border-bottom-right-radius: 12px; } """) layout = QVBoxLayout(status_widget) layout.setContentsMargins(0, 20, 0, 20) layout.setSpacing(8) # Status title with better typography status_title = QLabel("Service Status") status_title.setFont(QFont("SF Pro Text", 11, QFont.Weight.Bold)) status_title.setStyleSheet(""" color: rgba(255, 255, 255, 0.9); padding: 0 20px; margin-bottom: 8px; letter-spacing: 0.2px; font-weight: 600; """) layout.addWidget(status_title) # Status indicators self.spotify_status = StatusIndicator("Spotify") # Dynamic media server status - determine which server is active from config.settings import config_manager active_server = config_manager.get_active_media_server() server_name = "Plex" if active_server == "plex" else "Jellyfin" self.media_server_status = StatusIndicator(server_name) self.soulseek_status = StatusIndicator("Soulseek") layout.addWidget(self.spotify_status) layout.addWidget(self.media_server_status) layout.addWidget(self.soulseek_status) return status_widget def change_page(self, page_id: str): if page_id != self.current_page: # Update button states for btn_id, button in self.buttons.items(): button.set_active(btn_id == page_id) self.current_page = page_id self.page_changed.emit(page_id) def update_service_status(self, service: str, connected: bool): status_map = { "spotify": self.spotify_status, "plex": self.media_server_status, "jellyfin": self.media_server_status, "soulseek": self.soulseek_status } if service in status_map: status_map[service].update_status(connected) def update_media_server_name(self, server_type: str): """Update the media server status indicator name""" server_name = "Plex" if server_type == "plex" else "Jellyfin" if hasattr(self, 'media_server_status'): self.media_server_status.update_name(server_name) def show_version_info(self): """Show the version information modal""" try: from ui.components.version_info_modal import VersionInfoModal modal = VersionInfoModal(self) modal.exec() except Exception as e: logger = get_logger("sidebar") logger.error(f"Error showing version info modal: {e}")