3255 lines
No EOL
130 KiB
Python
3255 lines
No EOL
130 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QFrame, QPushButton, QProgressBar, QListWidget,
|
||
QListWidgetItem, QComboBox, QLineEdit, QScrollArea, QMessageBox,
|
||
QSplitter, QSizePolicy, QSpacerItem, QTabWidget)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QUrl
|
||
from PyQt6.QtGui import QFont
|
||
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||
import functools # For fixing lambda memory leaks
|
||
import os
|
||
|
||
class AudioPlayer(QMediaPlayer):
|
||
"""Simple audio player for streaming music files"""
|
||
playback_finished = pyqtSignal()
|
||
playback_error = pyqtSignal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
|
||
# Set up audio output
|
||
self.audio_output = QAudioOutput()
|
||
self.setAudioOutput(self.audio_output)
|
||
|
||
# Connect signals
|
||
self.mediaStatusChanged.connect(self._on_media_status_changed)
|
||
self.errorOccurred.connect(self._on_error_occurred)
|
||
|
||
# Track current file
|
||
self.current_file_path = None
|
||
self.is_playing = False
|
||
|
||
def play_file(self, file_path):
|
||
"""Play an audio file from the given path"""
|
||
try:
|
||
if not file_path or not os.path.exists(file_path):
|
||
self.playback_error.emit(f"File not found: {file_path}")
|
||
return False
|
||
|
||
# Stop any current playback
|
||
self.stop()
|
||
|
||
# Set the new media source
|
||
self.current_file_path = file_path
|
||
self.setSource(QUrl.fromLocalFile(file_path))
|
||
|
||
# Start playback
|
||
self.play()
|
||
self.is_playing = True
|
||
|
||
print(f"🎵 Started playing: {os.path.basename(file_path)}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
error_msg = f"Error playing audio file: {str(e)}"
|
||
print(error_msg)
|
||
self.playback_error.emit(error_msg)
|
||
return False
|
||
|
||
def toggle_playback(self):
|
||
"""Toggle between play and pause"""
|
||
if self.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
||
self.pause()
|
||
self.is_playing = False
|
||
return False # Now paused
|
||
else:
|
||
self.play()
|
||
self.is_playing = True
|
||
return True # Now playing
|
||
|
||
def stop_playback(self):
|
||
"""Stop playback and reset"""
|
||
self.stop()
|
||
self.is_playing = False
|
||
self.current_file_path = None
|
||
|
||
def _on_media_status_changed(self, status):
|
||
"""Handle media status changes"""
|
||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||
print("🎵 Playback finished")
|
||
self.is_playing = False
|
||
self.playback_finished.emit()
|
||
elif status == QMediaPlayer.MediaStatus.InvalidMedia:
|
||
error_msg = "Invalid media file or unsupported format"
|
||
print(f"❌ {error_msg}")
|
||
self.is_playing = False
|
||
self.playback_error.emit(error_msg)
|
||
|
||
def _on_error_occurred(self, error, error_string):
|
||
"""Handle playback errors"""
|
||
error_msg = f"Audio playback error: {error_string}"
|
||
print(f"❌ {error_msg}")
|
||
self.is_playing = False
|
||
self.playback_error.emit(error_msg)
|
||
|
||
class DownloadThread(QThread):
|
||
download_completed = pyqtSignal(str, object) # Download ID or success message, download_item
|
||
download_failed = pyqtSignal(str, object) # Error message, download_item
|
||
download_progress = pyqtSignal(str, object) # Progress message, download_item
|
||
|
||
def __init__(self, soulseek_client, search_result, download_item):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.search_result = search_result
|
||
self.download_item = download_item
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
self.download_progress.emit(f"Starting download: {self.search_result.filename}", self.download_item)
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Perform download with proper error handling
|
||
download_id = loop.run_until_complete(self._do_download())
|
||
|
||
if not self._stop_requested:
|
||
if download_id:
|
||
self.download_completed.emit(f"Download started: {download_id}", self.download_item)
|
||
else:
|
||
self.download_failed.emit("Download failed to start", self.download_item)
|
||
|
||
# Give signals time to be processed before thread exits
|
||
import time
|
||
time.sleep(0.1)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.download_failed.emit(str(e), self.download_item)
|
||
# Give error signal time to be processed
|
||
import time
|
||
time.sleep(0.1)
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up download event loop: {e}")
|
||
|
||
async def _do_download(self):
|
||
"""Perform the actual download with proper async handling"""
|
||
return await self.soulseek_client.download(
|
||
self.search_result.username,
|
||
self.search_result.filename,
|
||
self.search_result.size
|
||
)
|
||
|
||
def stop(self):
|
||
"""Stop the download gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class SessionInfoThread(QThread):
|
||
session_info_completed = pyqtSignal(dict) # Session info dict
|
||
session_info_failed = pyqtSignal(str) # Error message
|
||
|
||
def __init__(self, soulseek_client):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
# Get session info
|
||
session_info = loop.run_until_complete(self._get_session_info())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.session_info_completed.emit(session_info or {})
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.session_info_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up session info event loop: {e}")
|
||
|
||
async def _get_session_info(self):
|
||
"""Get the session information"""
|
||
return await self.soulseek_client.get_session_info()
|
||
|
||
def stop(self):
|
||
"""Stop the session info gathering gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class ExploreApiThread(QThread):
|
||
exploration_completed = pyqtSignal(dict) # API info dict
|
||
exploration_failed = pyqtSignal(str) # Error message
|
||
|
||
def __init__(self, soulseek_client):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
# Explore the API
|
||
api_info = loop.run_until_complete(self._explore_api())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.exploration_completed.emit(api_info)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.exploration_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up exploration event loop: {e}")
|
||
|
||
async def _explore_api(self):
|
||
"""Perform the actual API exploration"""
|
||
return await self.soulseek_client.explore_api_endpoints()
|
||
|
||
def stop(self):
|
||
"""Stop the exploration gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class SearchThread(QThread):
|
||
search_completed = pyqtSignal(list) # List of search results
|
||
search_failed = pyqtSignal(str) # Error message
|
||
search_progress = pyqtSignal(str) # Progress message
|
||
search_results_partial = pyqtSignal(list, int) # Partial results, total count
|
||
|
||
def __init__(self, soulseek_client, query):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.query = query
|
||
self._stop_requested = False
|
||
self.all_results = [] # Track all results for final emit
|
||
|
||
def progress_callback(self, new_results, total_count):
|
||
"""Callback function for progressive search results"""
|
||
if not self._stop_requested:
|
||
self.all_results.extend(new_results)
|
||
self.search_results_partial.emit(new_results, total_count)
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
self.search_progress.emit(f"Searching for: {self.query}")
|
||
|
||
# Create a completely fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Perform search with progressive callback
|
||
results = loop.run_until_complete(self._do_search())
|
||
|
||
if not self._stop_requested:
|
||
# Emit final completion with all results
|
||
self.search_completed.emit(self.all_results if self.all_results else results)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.search_failed.emit(str(e))
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up event loop: {e}")
|
||
|
||
async def _do_search(self):
|
||
"""Perform the actual search with progressive callback"""
|
||
return await self.soulseek_client.search(self.query, progress_callback=self.progress_callback)
|
||
|
||
def stop(self):
|
||
"""Stop the search gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class TrackedStatusUpdateThread(QThread):
|
||
"""Tracked status update thread that can be properly stopped and cleaned up"""
|
||
status_updated = pyqtSignal(list)
|
||
|
||
def __init__(self, soulseek_client, parent=None):
|
||
super().__init__(parent)
|
||
self.soulseek_client = soulseek_client
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
|
||
# Check if stop was requested before starting
|
||
if self._stop_requested:
|
||
return
|
||
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
downloads = loop.run_until_complete(self.soulseek_client.get_all_downloads())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.status_updated.emit(downloads or [])
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
print(f"Error fetching download status: {e}")
|
||
self.status_updated.emit([])
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up status update event loop: {e}")
|
||
|
||
def stop(self):
|
||
"""Stop the status update thread gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class StreamingThread(QThread):
|
||
"""Thread for streaming audio files without saving them permanently"""
|
||
streaming_started = pyqtSignal(str, object) # Message, search_result
|
||
streaming_finished = pyqtSignal(str, object) # Message, search_result
|
||
streaming_failed = pyqtSignal(str, object) # Error message, search_result
|
||
|
||
def __init__(self, soulseek_client, search_result):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.search_result = search_result
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
loop = None
|
||
try:
|
||
import asyncio
|
||
import os
|
||
import time
|
||
import shutil
|
||
import glob
|
||
from pathlib import Path
|
||
|
||
self.streaming_started.emit(f"Starting stream: {self.search_result.filename}", self.search_result)
|
||
|
||
# Create a fresh event loop for this thread
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Get paths
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
# Use the Stream folder in project root (not inside downloads)
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/
|
||
stream_folder = os.path.join(project_root, 'Stream')
|
||
|
||
# Ensure Stream directory exists
|
||
os.makedirs(stream_folder, exist_ok=True)
|
||
|
||
# Clear any existing files in Stream folder (only one file at a time)
|
||
for existing_file in glob.glob(os.path.join(stream_folder, '*')):
|
||
try:
|
||
if os.path.isfile(existing_file):
|
||
os.remove(existing_file)
|
||
elif os.path.isdir(existing_file):
|
||
shutil.rmtree(existing_file)
|
||
except Exception as e:
|
||
print(f"Warning: Could not remove existing stream file: {e}")
|
||
|
||
# Start the download (goes to normal downloads folder initially)
|
||
download_result = loop.run_until_complete(self._do_stream_download())
|
||
|
||
if not self._stop_requested:
|
||
if download_result:
|
||
self.streaming_started.emit(f"Downloading for stream: {self.search_result.filename}", self.search_result)
|
||
|
||
# Wait for download to complete and find the file
|
||
max_wait_time = 45 # Wait up to 45 seconds
|
||
poll_interval = 2 # Check every 2 seconds
|
||
found_file = None
|
||
|
||
for wait_count in range(max_wait_time // poll_interval):
|
||
if self._stop_requested:
|
||
break
|
||
|
||
# Search for the downloaded file in the downloads directory
|
||
found_file = self._find_downloaded_file(download_path)
|
||
|
||
if found_file:
|
||
print(f"✓ Found downloaded file: {found_file}")
|
||
|
||
# Move the file to Stream folder
|
||
file_extension = os.path.splitext(found_file)[1]
|
||
stream_filename = f"current_stream{file_extension}"
|
||
stream_path = os.path.join(stream_folder, stream_filename)
|
||
|
||
try:
|
||
# Move file to Stream folder
|
||
shutil.move(found_file, stream_path)
|
||
print(f"✓ Moved file to stream folder: {stream_path}")
|
||
|
||
# Clean up empty directories left behind
|
||
self._cleanup_empty_directories(download_path, found_file)
|
||
|
||
# Signal that streaming is ready
|
||
self.streaming_finished.emit(f"Stream ready: {os.path.basename(found_file)}", self.search_result)
|
||
self.temp_file_path = stream_path
|
||
print(f"✓ Stream file ready for playback: {stream_path}")
|
||
break
|
||
|
||
except Exception as e:
|
||
print(f"Error moving file to stream folder: {e}")
|
||
self.streaming_failed.emit(f"Failed to prepare stream file: {e}", self.search_result)
|
||
break
|
||
else:
|
||
# Still downloading, wait a bit more
|
||
print(f"Waiting for download to complete... ({wait_count * poll_interval}s elapsed)")
|
||
time.sleep(poll_interval)
|
||
else:
|
||
# Timed out waiting for file
|
||
self.streaming_failed.emit("Stream download timed out - file not found", self.search_result)
|
||
|
||
else:
|
||
self.streaming_failed.emit("Streaming failed to start", self.search_result)
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.streaming_failed.emit(str(e), self.search_result)
|
||
finally:
|
||
# Ensure proper cleanup
|
||
if loop:
|
||
try:
|
||
# Close any remaining tasks
|
||
pending = asyncio.all_tasks(loop)
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
|
||
loop.close()
|
||
except Exception as e:
|
||
print(f"Error cleaning up streaming event loop: {e}")
|
||
|
||
def _find_downloaded_file(self, download_path):
|
||
"""Find the downloaded audio file in the downloads directory tree"""
|
||
import os
|
||
|
||
# Audio file extensions to look for
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
|
||
# Get the base filename without path
|
||
target_filename = os.path.basename(self.search_result.filename)
|
||
|
||
try:
|
||
# Walk through the downloads directory to find the file
|
||
for root, dirs, files in os.walk(download_path):
|
||
for file in files:
|
||
# Check if this is our target file
|
||
if file == target_filename:
|
||
file_path = os.path.join(root, file)
|
||
# Verify it's an audio file and has content
|
||
if (os.path.splitext(file)[1].lower() in audio_extensions and
|
||
os.path.getsize(file_path) > 1024): # At least 1KB
|
||
return file_path
|
||
|
||
# Also check for any audio files that might match partially
|
||
# (in case filename is slightly different)
|
||
file_lower = file.lower()
|
||
target_lower = target_filename.lower()
|
||
|
||
# Remove common variations
|
||
target_clean = target_lower.replace(' ', '').replace('-', '').replace('_', '')
|
||
file_clean = file_lower.replace(' ', '').replace('-', '').replace('_', '')
|
||
|
||
if (os.path.splitext(file)[1].lower() in audio_extensions and
|
||
len(file_clean) > 10 and # Reasonable filename length
|
||
(target_clean in file_clean or file_clean in target_clean) and
|
||
os.path.getsize(os.path.join(root, file)) > 1024):
|
||
return os.path.join(root, file)
|
||
|
||
except Exception as e:
|
||
print(f"Error searching for downloaded file: {e}")
|
||
|
||
return None
|
||
|
||
def _cleanup_empty_directories(self, download_path, moved_file_path):
|
||
"""Clean up empty directories left after moving a file"""
|
||
import os
|
||
|
||
try:
|
||
# Get the directory that contained the moved file
|
||
file_dir = os.path.dirname(moved_file_path)
|
||
|
||
# Only clean up if it's a subdirectory of downloads (not the downloads folder itself)
|
||
if file_dir != download_path and file_dir.startswith(download_path):
|
||
# Check if directory is empty
|
||
if os.path.isdir(file_dir) and not os.listdir(file_dir):
|
||
print(f"Removing empty directory: {file_dir}")
|
||
os.rmdir(file_dir)
|
||
|
||
# Recursively check parent directories
|
||
parent_dir = os.path.dirname(file_dir)
|
||
if (parent_dir != download_path and
|
||
parent_dir.startswith(download_path) and
|
||
os.path.isdir(parent_dir) and
|
||
not os.listdir(parent_dir)):
|
||
print(f"Removing empty parent directory: {parent_dir}")
|
||
os.rmdir(parent_dir)
|
||
|
||
except Exception as e:
|
||
print(f"Warning: Could not clean up empty directories: {e}")
|
||
|
||
async def _do_stream_download(self):
|
||
"""Perform the streaming download using normal download mechanism"""
|
||
# Use the same download mechanism as regular downloads
|
||
# The file will be downloaded to the normal downloads folder first
|
||
return await self.soulseek_client.download(
|
||
self.search_result.username,
|
||
self.search_result.filename,
|
||
self.search_result.size
|
||
)
|
||
|
||
def stop(self):
|
||
"""Stop the streaming gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class SearchResultItem(QFrame):
|
||
download_requested = pyqtSignal(object) # SearchResult object
|
||
stream_requested = pyqtSignal(object) # SearchResult object for streaming
|
||
expansion_requested = pyqtSignal(object) # Signal when this item wants to expand
|
||
|
||
def __init__(self, search_result, parent=None):
|
||
super().__init__(parent)
|
||
self.search_result = search_result
|
||
self.is_downloading = False
|
||
self.is_expanded = False
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
# Dynamic height based on state (compact: 60px, expanded: 180px for better content fit)
|
||
self.compact_height = 60
|
||
self.expanded_height = 180 # Increased from 140px to fit content properly
|
||
self.setFixedHeight(self.compact_height)
|
||
|
||
# Ensure consistent sizing and layout behavior
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Enable mouse tracking for click detection
|
||
self.setMouseTracking(True)
|
||
|
||
self.setStyleSheet("""
|
||
SearchResultItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(42, 42, 42, 0.9),
|
||
stop:1 rgba(32, 32, 32, 0.95));
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(64, 64, 64, 0.4);
|
||
margin: 4px 2px;
|
||
}
|
||
SearchResultItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.95),
|
||
stop:1 rgba(40, 40, 40, 0.98));
|
||
border: 1px solid rgba(29, 185, 84, 0.7);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(12, 8, 12, 8) # Tighter padding for compact view
|
||
layout.setSpacing(12)
|
||
|
||
# Left section: Music icon + filename
|
||
left_section = QHBoxLayout()
|
||
left_section.setSpacing(8)
|
||
|
||
# Compact music icon
|
||
music_icon = QLabel("🎵")
|
||
music_icon.setFixedSize(32, 32)
|
||
music_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
music_icon.setStyleSheet("""
|
||
QLabel {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.3),
|
||
stop:1 rgba(29, 185, 84, 0.1));
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(29, 185, 84, 0.4);
|
||
font-size: 14px;
|
||
}
|
||
""")
|
||
|
||
# Content area that will change based on expanded state
|
||
self.content_widget = QWidget()
|
||
self.content_layout = QVBoxLayout(self.content_widget)
|
||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.content_layout.setSpacing(3) # Tighter spacing for better content density
|
||
|
||
# Extract song info
|
||
primary_info = self._extract_song_info()
|
||
|
||
# Create both compact and expanded content but show only one
|
||
self.create_persistent_content(primary_info)
|
||
|
||
# Right section: Play and download buttons
|
||
buttons_layout = QHBoxLayout()
|
||
buttons_layout.setSpacing(4)
|
||
|
||
# Play button for streaming preview
|
||
self.play_btn = QPushButton("▶️")
|
||
self.play_btn.setFixedSize(36, 36)
|
||
self.play_btn.clicked.connect(self.request_stream)
|
||
self.play_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.9),
|
||
stop:1 rgba(255, 152, 0, 0.9));
|
||
border: none;
|
||
border-radius: 18px;
|
||
color: #000000;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 213, 79, 1.0),
|
||
stop:1 rgba(255, 171, 64, 1.0));
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 152, 0, 1.0),
|
||
stop:1 rgba(245, 124, 0, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Download button
|
||
self.download_btn = QPushButton("⬇️")
|
||
self.download_btn.setFixedSize(36, 36)
|
||
self.download_btn.clicked.connect(self.request_download)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.9),
|
||
stop:1 rgba(24, 156, 71, 0.9));
|
||
border: none;
|
||
border-radius: 18px;
|
||
color: #000000;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(30, 215, 96, 1.0),
|
||
stop:1 rgba(25, 180, 80, 1.0));
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(24, 156, 71, 1.0),
|
||
stop:1 rgba(20, 130, 60, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Assemble the layout
|
||
left_section.addWidget(music_icon)
|
||
left_section.addWidget(self.content_widget, 1)
|
||
|
||
buttons_layout.addWidget(self.play_btn)
|
||
buttons_layout.addWidget(self.download_btn)
|
||
|
||
layout.addLayout(left_section, 1)
|
||
layout.addLayout(buttons_layout)
|
||
|
||
def create_persistent_content(self, primary_info):
|
||
"""Create both compact and expanded content with visibility control"""
|
||
# Title row (always visible) with character limit and ellipsis
|
||
title_text = primary_info['title']
|
||
if len(title_text) > 50: # Character limit for long titles
|
||
title_text = title_text[:47] + "..."
|
||
|
||
self.title_label = QLabel(title_text)
|
||
self.title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) # Reduced from 13px to 11px
|
||
self.title_label.setStyleSheet("color: #ffffff;")
|
||
self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
# Ensure text doesn't overflow the label and allow click-through
|
||
self.title_label.setWordWrap(False)
|
||
# Remove text selection to allow clicks to propagate to parent widget
|
||
self.title_label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
||
|
||
# Expand indicator
|
||
self.expand_indicator = QLabel("⏵")
|
||
self.expand_indicator.setFixedSize(16, 16)
|
||
self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.expand_indicator.setStyleSheet("color: rgba(255, 255, 255, 0.6); font-size: 12px;")
|
||
|
||
# Quality badge (only visible when expanded)
|
||
self.quality_badge = self._create_compact_quality_badge()
|
||
self.quality_badge.hide() # Initially hidden
|
||
|
||
title_row = QHBoxLayout()
|
||
title_row.setContentsMargins(0, 0, 0, 0)
|
||
title_row.addWidget(self.title_label)
|
||
title_row.addWidget(self.quality_badge)
|
||
title_row.addWidget(self.expand_indicator)
|
||
|
||
# Expanded content (initially hidden)
|
||
self.expanded_content = QWidget()
|
||
expanded_layout = QVBoxLayout(self.expanded_content)
|
||
expanded_layout.setContentsMargins(0, 0, 0, 0)
|
||
expanded_layout.setSpacing(1) # Ultra-tight spacing for more compact layout
|
||
|
||
# Artist info
|
||
self.artist_info = QLabel(primary_info['artist'])
|
||
self.artist_info.setFont(QFont("Arial", 10, QFont.Weight.Normal)) # Slightly smaller font
|
||
self.artist_info.setStyleSheet("color: rgba(179, 179, 179, 0.9);")
|
||
|
||
# File details
|
||
details = []
|
||
size_mb = self.search_result.size // (1024*1024)
|
||
details.append(f"{size_mb}MB")
|
||
|
||
if self.search_result.duration:
|
||
duration_mins = self.search_result.duration // 60
|
||
duration_secs = self.search_result.duration % 60
|
||
details.append(f"{duration_mins}:{duration_secs:02d}")
|
||
|
||
self.file_details = QLabel(" • ".join(details))
|
||
self.file_details.setFont(QFont("Arial", 9)) # Smaller font for compactness
|
||
self.file_details.setStyleSheet("color: rgba(136, 136, 136, 0.8);")
|
||
|
||
# User info and quality score in one compact row
|
||
bottom_row = QHBoxLayout()
|
||
bottom_row.setContentsMargins(0, 0, 0, 0)
|
||
bottom_row.setSpacing(8)
|
||
|
||
# Apply intelligent path truncation to username/file location
|
||
truncated_path = self._truncate_file_path(self.search_result.username, self.search_result.filename)
|
||
self.user_info = QLabel(f"👤 {truncated_path}")
|
||
self.user_info.setFont(QFont("Arial", 9, QFont.Weight.Medium)) # Smaller font
|
||
self.user_info.setStyleSheet("color: rgba(29, 185, 84, 0.8);")
|
||
|
||
self.speed_indicator = self._create_compact_speed_indicator()
|
||
|
||
# Add widgets to bottom row (removed misleading star rating)
|
||
bottom_row.addWidget(self.user_info)
|
||
bottom_row.addWidget(self.speed_indicator)
|
||
bottom_row.addStretch()
|
||
|
||
# Add all expanded content
|
||
expanded_layout.addWidget(self.artist_info)
|
||
expanded_layout.addWidget(self.file_details)
|
||
expanded_layout.addLayout(bottom_row)
|
||
|
||
# Initially hide expanded content
|
||
self.expanded_content.hide()
|
||
|
||
# Add to main layout
|
||
self.content_layout.addLayout(title_row)
|
||
self.content_layout.addWidget(self.expanded_content)
|
||
|
||
def update_expanded_state(self):
|
||
"""Update UI based on expanded state without recreating widgets"""
|
||
if self.is_expanded:
|
||
self.expand_indicator.setText("⏷")
|
||
self.quality_badge.show()
|
||
self.expanded_content.show()
|
||
else:
|
||
self.expand_indicator.setText("⏵")
|
||
self.quality_badge.hide()
|
||
self.expanded_content.hide()
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Handle mouse clicks to toggle expand/collapse"""
|
||
# Only respond to left clicks and avoid clicks on the download button
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
# Check if click is on download button (more precise detection)
|
||
button_rect = self.download_btn.geometry()
|
||
# Add some padding to the button area to be more forgiving
|
||
button_rect.adjust(-5, -5, 5, 5)
|
||
if not button_rect.contains(event.pos()):
|
||
# Emit signal to parent to handle accordion behavior
|
||
self.expansion_requested.emit(self)
|
||
super().mousePressEvent(event)
|
||
|
||
def set_expanded(self, expanded, animate=True):
|
||
"""Set expanded state externally (called by parent for accordion behavior)"""
|
||
if self.is_expanded == expanded:
|
||
return # No change needed
|
||
|
||
self.is_expanded = expanded
|
||
|
||
if animate:
|
||
self._animate_to_state()
|
||
else:
|
||
# Immediate state change without animation
|
||
if self.is_expanded:
|
||
self.setFixedHeight(self.expanded_height)
|
||
else:
|
||
self.setFixedHeight(self.compact_height)
|
||
self.update_expanded_state()
|
||
|
||
def toggle_expanded(self):
|
||
"""Toggle between compact and expanded states with animation"""
|
||
self.set_expanded(not self.is_expanded, animate=True)
|
||
|
||
def _animate_to_state(self):
|
||
"""Animate to the current expanded state"""
|
||
from PyQt6.QtCore import QPropertyAnimation, QEasingCurve
|
||
|
||
# Start height animation first
|
||
self.animation = QPropertyAnimation(self, b"minimumHeight")
|
||
self.animation.setDuration(200) # Slightly faster animation for better responsiveness
|
||
self.animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||
|
||
if self.is_expanded:
|
||
# Expand animation
|
||
self.animation.setStartValue(self.compact_height)
|
||
self.animation.setEndValue(self.expanded_height)
|
||
# Show content immediately for expand (feels more responsive)
|
||
self.update_expanded_state()
|
||
else:
|
||
# Collapse animation
|
||
self.animation.setStartValue(self.expanded_height)
|
||
self.animation.setEndValue(self.compact_height)
|
||
# Hide content immediately for collapse (cleaner look)
|
||
self.update_expanded_state()
|
||
|
||
# Update fixed height when animation completes
|
||
self.animation.finished.connect(self._finalize_height)
|
||
self.animation.start()
|
||
|
||
def _finalize_height(self):
|
||
"""Set final height after animation completes"""
|
||
if self.is_expanded:
|
||
self.setFixedHeight(self.expanded_height)
|
||
else:
|
||
self.setFixedHeight(self.compact_height)
|
||
|
||
# Force parent layout update to ensure proper spacing
|
||
if self.parent():
|
||
self.parent().updateGeometry()
|
||
|
||
def sizeHint(self):
|
||
"""Provide consistent size hint for layout calculations"""
|
||
if self.is_expanded:
|
||
return self.size().expandedTo(self.minimumSize()).boundedTo(self.maximumSize())
|
||
else:
|
||
return self.size().expandedTo(self.minimumSize()).boundedTo(self.maximumSize())
|
||
|
||
def _truncate_file_path(self, username, filename):
|
||
"""Truncate file path to show max 3 levels: file + parent + grandparent folder"""
|
||
import os
|
||
|
||
# If username looks like a simple username (no path separators), return as-is
|
||
if '/' not in username and '\\' not in username:
|
||
return username
|
||
|
||
# Get filename without extension for comparison
|
||
file_base = os.path.splitext(os.path.basename(filename))[0]
|
||
|
||
# Split path using both Windows and Unix separators
|
||
path_parts = username.replace('\\', '/').split('/')
|
||
|
||
# Remove empty parts
|
||
path_parts = [part for part in path_parts if part.strip()]
|
||
|
||
# If path is already short, return as-is
|
||
if len(path_parts) <= 3:
|
||
return '/'.join(path_parts)
|
||
|
||
# Take last 3 components (file + parent + grandparent)
|
||
truncated_parts = path_parts[-3:]
|
||
|
||
# If we truncated, add ellipsis at the beginning
|
||
if len(path_parts) > 3:
|
||
return '.../' + '/'.join(truncated_parts)
|
||
else:
|
||
return '/'.join(truncated_parts)
|
||
|
||
def _extract_song_info(self):
|
||
"""Extract song title and artist from filename"""
|
||
filename = self.search_result.filename
|
||
|
||
# Remove file extension
|
||
name_without_ext = filename.rsplit('.', 1)[0]
|
||
|
||
# Common patterns for artist - title separation
|
||
separators = [' - ', ' – ', ' — ', '_-_', ' | ']
|
||
|
||
for sep in separators:
|
||
if sep in name_without_ext:
|
||
parts = name_without_ext.split(sep, 1)
|
||
return {
|
||
'title': parts[1].strip(),
|
||
'artist': parts[0].strip()
|
||
}
|
||
|
||
# If no separator found, use filename as title
|
||
return {
|
||
'title': name_without_ext,
|
||
'artist': 'Unknown Artist'
|
||
}
|
||
|
||
def _create_compact_quality_badge(self):
|
||
"""Create a compact quality indicator badge"""
|
||
quality = self.search_result.quality.upper()
|
||
bitrate = self.search_result.bitrate
|
||
|
||
if quality == 'FLAC':
|
||
badge_text = "FLAC"
|
||
badge_color = "#1db954"
|
||
elif bitrate and bitrate >= 320:
|
||
badge_text = f"{bitrate}k"
|
||
badge_color = "#1db954"
|
||
elif bitrate and bitrate >= 256:
|
||
badge_text = f"{bitrate}k"
|
||
badge_color = "#ffa500"
|
||
elif bitrate and bitrate >= 192:
|
||
badge_text = f"{bitrate}k"
|
||
badge_color = "#ffaa00"
|
||
else:
|
||
badge_text = quality[:3] # Truncate for compact display
|
||
badge_color = "#e22134"
|
||
|
||
badge = QLabel(badge_text)
|
||
badge.setFont(QFont("Arial", 8, QFont.Weight.Bold))
|
||
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
badge.setFixedSize(40, 16)
|
||
badge.setStyleSheet(f"""
|
||
QLabel {{
|
||
background: {badge_color};
|
||
color: #000000;
|
||
border-radius: 8px;
|
||
padding: 1px 4px;
|
||
}}
|
||
""")
|
||
|
||
return badge
|
||
|
||
def _create_compact_speed_indicator(self):
|
||
"""Create compact upload speed indicator"""
|
||
speed = self.search_result.upload_speed
|
||
slots = self.search_result.free_upload_slots
|
||
|
||
if slots > 0 and speed > 100:
|
||
indicator_color = "#1db954"
|
||
speed_text = "🚀"
|
||
elif slots > 0:
|
||
indicator_color = "#ffa500"
|
||
speed_text = "⚡"
|
||
else:
|
||
indicator_color = "#e22134"
|
||
speed_text = "⏳"
|
||
|
||
indicator = QLabel(speed_text)
|
||
indicator.setFont(QFont("Arial", 10))
|
||
indicator.setStyleSheet(f"color: {indicator_color};")
|
||
indicator.setFixedSize(16, 16)
|
||
|
||
return indicator
|
||
|
||
def _create_quality_badge(self):
|
||
"""Create a quality indicator badge (legacy - kept for compatibility)"""
|
||
return self._create_compact_quality_badge()
|
||
|
||
def _create_speed_indicator(self):
|
||
"""Create upload speed indicator (legacy - kept for compatibility)"""
|
||
return self._create_compact_speed_indicator()
|
||
|
||
def request_download(self):
|
||
if not self.is_downloading:
|
||
self.is_downloading = True
|
||
self.download_btn.setText("⏳")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_requested.emit(self.search_result)
|
||
|
||
def request_stream(self):
|
||
"""Request streaming of this audio file"""
|
||
# Check if file is a valid audio type
|
||
audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav']
|
||
filename_lower = self.search_result.filename.lower()
|
||
|
||
is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions)
|
||
|
||
if is_audio:
|
||
# Get reference to the DownloadsPage to check audio player state
|
||
downloads_page = self.get_downloads_page()
|
||
|
||
# If this button is currently playing, toggle pause/resume
|
||
if (downloads_page and
|
||
downloads_page.currently_playing_button == self and
|
||
downloads_page.audio_player.is_playing):
|
||
|
||
# Toggle playback (pause/resume)
|
||
is_playing = downloads_page.audio_player.toggle_playback()
|
||
if is_playing:
|
||
self.set_playing_state()
|
||
else:
|
||
self.play_btn.setText("▶️") # Play icon when paused
|
||
self.play_btn.setEnabled(True)
|
||
return
|
||
|
||
# Otherwise, start new streaming
|
||
# Change button state to indicate streaming is starting
|
||
self.play_btn.setText("⏸️") # Pause icon to indicate playing
|
||
self.play_btn.setEnabled(False)
|
||
|
||
# Emit streaming request
|
||
self.stream_requested.emit(self.search_result)
|
||
|
||
# Note: Button state will be managed by the audio player callbacks
|
||
# No timer reset - the audio player will handle state changes
|
||
else:
|
||
print(f"Cannot stream non-audio file: {self.search_result.filename}")
|
||
|
||
def get_downloads_page(self):
|
||
"""Get reference to the parent DownloadsPage"""
|
||
parent = self.parent()
|
||
while parent:
|
||
if hasattr(parent, 'audio_player'): # DownloadsPage has audio_player
|
||
return parent
|
||
parent = parent.parent()
|
||
return None
|
||
|
||
def reset_play_state(self, original_text="▶️"):
|
||
"""Reset the play button state"""
|
||
self.play_btn.setText(original_text)
|
||
self.play_btn.setEnabled(True)
|
||
|
||
def set_playing_state(self):
|
||
"""Set button to playing state"""
|
||
self.play_btn.setText("⏸️")
|
||
self.play_btn.setEnabled(True)
|
||
|
||
def set_loading_state(self):
|
||
"""Set button to loading state"""
|
||
self.play_btn.setText("⌛")
|
||
self.play_btn.setEnabled(False)
|
||
|
||
def reset_download_state(self):
|
||
"""Reset the download button state"""
|
||
self.is_downloading = False
|
||
self.download_btn.setText("⬇️")
|
||
self.download_btn.setEnabled(True)
|
||
|
||
class DownloadItem(QFrame):
|
||
def __init__(self, title: str, artist: str, status: str, progress: int = 0,
|
||
file_size: int = 0, download_speed: int = 0, file_path: str = "",
|
||
download_id: str = "", soulseek_client=None, parent=None):
|
||
super().__init__(parent)
|
||
self.title = title
|
||
self.artist = artist
|
||
self.status = status
|
||
self.progress = progress
|
||
self.file_size = file_size
|
||
self.download_speed = download_speed
|
||
self.file_path = file_path
|
||
self.download_id = download_id # Track download ID for cancellation
|
||
self.soulseek_client = soulseek_client # For cancellation functionality
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(90) # Consistent with search results
|
||
self.setStyleSheet("""
|
||
DownloadItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(42, 42, 42, 0.9),
|
||
stop:1 rgba(32, 32, 32, 0.95));
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(64, 64, 64, 0.4);
|
||
margin: 6px 4px;
|
||
}
|
||
DownloadItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.95),
|
||
stop:1 rgba(40, 40, 40, 0.98));
|
||
border: 1px solid rgba(29, 185, 84, 0.7);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(16, 12, 16, 12) # Consistent with search items
|
||
layout.setSpacing(16) # Professional spacing
|
||
|
||
# Status icon
|
||
status_icon = QLabel()
|
||
status_icon.setFixedSize(32, 32)
|
||
status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
if self.status == "downloading":
|
||
status_icon.setText("📥")
|
||
status_icon.setStyleSheet("""
|
||
QLabel {
|
||
color: #1db954;
|
||
font-size: 18px;
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border-radius: 16px;
|
||
}
|
||
""")
|
||
elif self.status == "completed":
|
||
status_icon.setText("✅")
|
||
status_icon.setStyleSheet("""
|
||
QLabel {
|
||
color: #1db954;
|
||
font-size: 18px;
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border-radius: 16px;
|
||
}
|
||
""")
|
||
elif self.status == "failed":
|
||
status_icon.setText("❌")
|
||
status_icon.setStyleSheet("""
|
||
QLabel {
|
||
color: #e22134;
|
||
font-size: 18px;
|
||
background: rgba(226, 33, 52, 0.1);
|
||
border-radius: 16px;
|
||
}
|
||
""")
|
||
else:
|
||
status_icon.setText("⏳")
|
||
status_icon.setStyleSheet("""
|
||
QLabel {
|
||
color: #ffa500;
|
||
font-size: 18px;
|
||
background: rgba(255, 165, 0, 0.1);
|
||
border-radius: 16px;
|
||
}
|
||
""")
|
||
|
||
# Content
|
||
content_layout = QVBoxLayout()
|
||
content_layout.setSpacing(5)
|
||
|
||
# Title and artist
|
||
title_label = QLabel(self.title)
|
||
title_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
title_label.setStyleSheet("color: #ffffff;")
|
||
|
||
artist_label = QLabel(f"by {self.artist}")
|
||
artist_label.setFont(QFont("Arial", 10))
|
||
artist_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
content_layout.addWidget(title_label)
|
||
content_layout.addWidget(artist_label)
|
||
|
||
# Progress section
|
||
progress_layout = QVBoxLayout()
|
||
progress_layout.setSpacing(5)
|
||
|
||
# Progress bar - Store reference for safe updates
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setFixedHeight(6)
|
||
self.progress_bar.setValue(self.progress)
|
||
self.progress_bar.setStyleSheet("""
|
||
QProgressBar {
|
||
border: none;
|
||
border-radius: 3px;
|
||
background: #404040;
|
||
}
|
||
QProgressBar::chunk {
|
||
background: #1db954;
|
||
border-radius: 3px;
|
||
}
|
||
""")
|
||
|
||
# Status text - Store reference for safe updates with clean mapping
|
||
status_mapping = {
|
||
"completed, succeeded": "Finished",
|
||
"completed, cancelled": "Cancelled",
|
||
"completed": "Finished",
|
||
"cancelled": "Cancelled",
|
||
"downloading": "Downloading",
|
||
"failed": "Failed",
|
||
"queued": "Queued"
|
||
}
|
||
|
||
clean_status = status_mapping.get(self.status.lower(), self.status.title())
|
||
status_text = clean_status
|
||
|
||
if self.status.lower() in ["downloading", "queued"]:
|
||
status_text += f" - {self.progress}%"
|
||
|
||
self.status_label = QLabel(status_text)
|
||
self.status_label.setFont(QFont("Arial", 9))
|
||
self.status_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
progress_layout.addWidget(self.progress_bar)
|
||
progress_layout.addWidget(self.status_label)
|
||
|
||
# Action buttons section
|
||
actions_layout = QVBoxLayout()
|
||
actions_layout.setSpacing(4)
|
||
|
||
# Primary action button
|
||
self.action_btn = QPushButton()
|
||
self.action_btn.setFixedSize(80, 28)
|
||
|
||
if self.status == "downloading":
|
||
self.action_btn.setText("Cancel")
|
||
self.action_btn.clicked.connect(self.cancel_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #e22134;
|
||
border-radius: 14px;
|
||
color: #e22134;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #e22134;
|
||
color: #ffffff;
|
||
}
|
||
""")
|
||
elif self.status == "failed":
|
||
self.action_btn.setText("Retry")
|
||
self.action_btn.clicked.connect(self.retry_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #1db954;
|
||
border-radius: 14px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1db954;
|
||
color: #000000;
|
||
}
|
||
""")
|
||
else:
|
||
self.action_btn.setText("Details")
|
||
self.action_btn.clicked.connect(self.show_details)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #b3b3b3;
|
||
border-radius: 14px;
|
||
color: #b3b3b3;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #b3b3b3;
|
||
color: #000000;
|
||
}
|
||
""")
|
||
|
||
# Open Location button (for completed downloads)
|
||
location_btn = QPushButton("📂 Open")
|
||
location_btn.setFixedSize(80, 28)
|
||
location_btn.clicked.connect(self.open_download_location)
|
||
location_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 14px;
|
||
color: rgba(29, 185, 84, 0.9);
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border: 1px solid rgba(29, 185, 84, 0.8);
|
||
}
|
||
""")
|
||
|
||
actions_layout.addWidget(self.action_btn)
|
||
if self.status == "completed" and self.file_path:
|
||
actions_layout.addWidget(location_btn)
|
||
|
||
layout.addWidget(status_icon)
|
||
layout.addLayout(content_layout)
|
||
layout.addStretch()
|
||
layout.addLayout(progress_layout)
|
||
layout.addLayout(actions_layout)
|
||
|
||
def open_download_location(self):
|
||
"""Open the download location in file explorer"""
|
||
import os
|
||
import platform
|
||
from pathlib import Path
|
||
|
||
if not self.file_path:
|
||
return
|
||
|
||
try:
|
||
file_path = Path(self.file_path)
|
||
if file_path.exists():
|
||
# Open the folder containing the file
|
||
folder_path = file_path.parent
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(str(folder_path))
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{folder_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{folder_path}"')
|
||
else:
|
||
# If file doesn't exist, try to open the download directory from config
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(download_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{download_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{download_path}"')
|
||
|
||
except Exception as e:
|
||
print(f"Error opening download location: {e}")
|
||
|
||
def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None):
|
||
"""SAFE UPDATE: Update download item status without UI destruction"""
|
||
# Update properties
|
||
self.status = status
|
||
if progress is not None:
|
||
self.progress = progress
|
||
if download_speed is not None:
|
||
self.download_speed = download_speed
|
||
if file_path:
|
||
self.file_path = file_path
|
||
|
||
# SAFE UI UPDATES: Update widgets directly instead of recreating
|
||
try:
|
||
# Update progress bar safely
|
||
if hasattr(self, 'progress_bar') and self.progress_bar:
|
||
self.progress_bar.setValue(self.progress)
|
||
|
||
# Update status label safely
|
||
if hasattr(self, 'status_label') and self.status_label:
|
||
# Clean up status text display
|
||
status_mapping = {
|
||
"completed, succeeded": "Finished",
|
||
"completed, cancelled": "Cancelled",
|
||
"completed": "Finished",
|
||
"cancelled": "Cancelled",
|
||
"downloading": "Downloading",
|
||
"failed": "Failed",
|
||
"queued": "Queued"
|
||
}
|
||
|
||
clean_status = status_mapping.get(self.status.lower(), self.status.title())
|
||
status_text = clean_status
|
||
|
||
if self.status.lower() in ["downloading", "queued"]:
|
||
status_text += f" - {self.progress}%"
|
||
|
||
self.status_label.setText(status_text)
|
||
|
||
except Exception as e:
|
||
print(f"Error updating download item UI: {e}")
|
||
# Fallback: only recreate if safe update fails
|
||
self.setup_ui()
|
||
|
||
def cancel_download(self):
|
||
"""Cancel the download using the SoulseekClient"""
|
||
if not self.soulseek_client or not self.download_id:
|
||
print(f"Cannot cancel download: missing client or download ID")
|
||
return
|
||
|
||
try:
|
||
# Use async cancellation in a simple way
|
||
import asyncio
|
||
loop = None
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Run the cancellation
|
||
result = loop.run_until_complete(self.soulseek_client.cancel_download(self.download_id))
|
||
|
||
if result:
|
||
print(f"Successfully cancelled download: {self.title}")
|
||
self.update_status("cancelled", progress=0)
|
||
|
||
# Find the parent TabbedDownloadManager and move to finished tab
|
||
parent_widget = self.parent()
|
||
while parent_widget:
|
||
if hasattr(parent_widget, 'move_to_finished'):
|
||
parent_widget.move_to_finished(self)
|
||
break
|
||
parent_widget = parent_widget.parent()
|
||
|
||
else:
|
||
print(f"Failed to cancel download: {self.title}")
|
||
|
||
except Exception as e:
|
||
print(f"Error cancelling download {self.title}: {e}")
|
||
|
||
def retry_download(self):
|
||
"""Retry a failed download"""
|
||
# For now, just update status back to downloading
|
||
# In a full implementation, this would restart the download
|
||
self.update_status("downloading", progress=0)
|
||
print(f"Retry requested for: {self.title}")
|
||
|
||
def show_details(self):
|
||
"""Show download details"""
|
||
details = f"""
|
||
Download Details:
|
||
Title: {self.title}
|
||
Artist: {self.artist}
|
||
Status: {self.status}
|
||
Progress: {self.progress}%
|
||
File Size: {self.file_size // (1024*1024)}MB
|
||
Download ID: {self.download_id}
|
||
File Path: {self.file_path}
|
||
"""
|
||
print(details)
|
||
|
||
class DownloadQueue(QFrame):
|
||
def __init__(self, title="Download Queue", parent=None):
|
||
super().__init__(parent)
|
||
self.queue_title = title
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setStyleSheet("""
|
||
DownloadQueue {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.9),
|
||
stop:1 rgba(35, 35, 35, 0.95));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(80, 80, 80, 0.5);
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(12, 6, 12, 12) # Further reduced padding
|
||
layout.setSpacing(6) # Even tighter spacing for more compact layout
|
||
|
||
# Header
|
||
header_layout = QHBoxLayout()
|
||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.title_label = QLabel(self.queue_title)
|
||
self.title_label.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold))
|
||
self.title_label.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.95);
|
||
font-weight: 600;
|
||
padding: 0;
|
||
margin: 0;
|
||
""")
|
||
|
||
queue_count = QLabel("Empty")
|
||
queue_count.setFont(QFont("Segoe UI", 9))
|
||
queue_count.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.6);
|
||
padding: 0;
|
||
margin: 0;
|
||
""")
|
||
|
||
header_layout.addWidget(self.title_label)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(queue_count)
|
||
|
||
# Queue list
|
||
queue_scroll = QScrollArea()
|
||
queue_scroll.setWidgetResizable(True)
|
||
queue_scroll.setFixedHeight(280)
|
||
queue_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #404040;
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
queue_widget = QWidget()
|
||
queue_layout = QVBoxLayout(queue_widget)
|
||
queue_layout.setSpacing(8)
|
||
|
||
# Dynamic download items - initially empty
|
||
self.queue_layout = queue_layout
|
||
self.queue_count_label = queue_count
|
||
self.download_items = []
|
||
|
||
# Add initial message when queue is empty
|
||
self.empty_message = QLabel("No downloads yet. Start downloading music to see them here!")
|
||
self.empty_message.setFont(QFont("Arial", 10))
|
||
self.empty_message.setStyleSheet("color: rgba(255, 255, 255, 0.5); padding: 15px; text-align: center;")
|
||
self.empty_message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
queue_layout.addWidget(self.empty_message)
|
||
|
||
queue_layout.addStretch()
|
||
queue_scroll.setWidget(queue_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(queue_scroll)
|
||
|
||
def add_download_item(self, title: str, artist: str, status: str = "queued",
|
||
progress: int = 0, file_size: int = 0, download_speed: int = 0,
|
||
file_path: str = "", download_id: str = "", soulseek_client=None):
|
||
"""Add a new download item to the queue"""
|
||
# Hide empty message if this is the first item
|
||
if len(self.download_items) == 0:
|
||
self.empty_message.hide()
|
||
|
||
# Create new download item
|
||
item = DownloadItem(title, artist, status, progress, file_size, download_speed, file_path, download_id, soulseek_client)
|
||
self.download_items.append(item)
|
||
|
||
# Insert before the stretch (which is always last)
|
||
insert_index = self.queue_layout.count() - 1
|
||
self.queue_layout.insertWidget(insert_index, item)
|
||
|
||
# Update count
|
||
self.update_queue_count()
|
||
|
||
return item
|
||
|
||
def update_queue_count(self):
|
||
"""Update the queue count label"""
|
||
count = len(self.download_items)
|
||
if count == 0:
|
||
self.queue_count_label.setText("Empty")
|
||
if not self.empty_message.isHidden():
|
||
self.empty_message.show()
|
||
else:
|
||
self.queue_count_label.setText(f"{count} item{'s' if count != 1 else ''}")
|
||
|
||
def remove_download_item(self, item):
|
||
"""Remove a download item from the queue"""
|
||
if item in self.download_items:
|
||
self.download_items.remove(item)
|
||
self.queue_layout.removeWidget(item)
|
||
item.deleteLater()
|
||
self.update_queue_count()
|
||
|
||
def clear_completed_downloads(self):
|
||
"""Remove all completed download items"""
|
||
items_to_remove = []
|
||
for item in self.download_items:
|
||
if item.status == "completed":
|
||
items_to_remove.append(item)
|
||
|
||
for item in items_to_remove:
|
||
self.remove_download_item(item)
|
||
|
||
class TabbedDownloadManager(QTabWidget):
|
||
"""Tabbed interface for managing active and finished downloads"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
"""Setup the tabbed interface with active and finished download queues"""
|
||
self.setStyleSheet("""
|
||
QTabWidget::pane {
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
background: #282828;
|
||
}
|
||
QTabWidget::tab-bar {
|
||
alignment: center;
|
||
}
|
||
QTabBar::tab {
|
||
background: #404040;
|
||
color: #ffffff;
|
||
border: 1px solid #606060;
|
||
border-bottom: none;
|
||
border-top-left-radius: 8px;
|
||
border-top-right-radius: 8px;
|
||
padding: 6px 12px;
|
||
margin-right: 1px;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
min-width: 80px;
|
||
}
|
||
QTabBar::tab:selected {
|
||
background: #1db954;
|
||
color: #000000;
|
||
border: 1px solid #1db954;
|
||
}
|
||
QTabBar::tab:hover:!selected {
|
||
background: #505050;
|
||
}
|
||
""")
|
||
|
||
# Create two download queues with appropriate titles
|
||
self.active_queue = DownloadQueue("Active Downloads")
|
||
self.finished_queue = DownloadQueue("Finished Downloads")
|
||
|
||
# Update the finished queue count label
|
||
self.finished_queue.queue_count_label.setText("Empty")
|
||
|
||
# Add tabs
|
||
self.addTab(self.active_queue, "Download Queue")
|
||
self.addTab(self.finished_queue, "Finished Downloads")
|
||
|
||
# Set initial tab counts
|
||
self.update_tab_counts()
|
||
|
||
def add_download_item(self, title: str, artist: str, status: str = "queued",
|
||
progress: int = 0, file_size: int = 0, download_speed: int = 0,
|
||
file_path: str = "", download_id: str = "", soulseek_client=None):
|
||
"""Add a new download item to the active queue"""
|
||
item = self.active_queue.add_download_item(
|
||
title, artist, status, progress, file_size, download_speed,
|
||
file_path, download_id, soulseek_client
|
||
)
|
||
self.update_tab_counts()
|
||
return item
|
||
|
||
def move_to_finished(self, download_item):
|
||
"""Move a download item from active to finished queue"""
|
||
if download_item in self.active_queue.download_items:
|
||
# Remove from active queue
|
||
self.active_queue.remove_download_item(download_item)
|
||
|
||
# Add to finished queue
|
||
finished_item = self.finished_queue.add_download_item(
|
||
title=download_item.title,
|
||
artist=download_item.artist,
|
||
status=download_item.status,
|
||
progress=download_item.progress,
|
||
file_size=download_item.file_size,
|
||
download_speed=download_item.download_speed,
|
||
file_path=download_item.file_path,
|
||
download_id=download_item.download_id,
|
||
soulseek_client=download_item.soulseek_client
|
||
)
|
||
|
||
self.update_tab_counts()
|
||
return finished_item
|
||
return None
|
||
|
||
def update_tab_counts(self):
|
||
"""Update tab labels with current counts"""
|
||
active_count = len(self.active_queue.download_items)
|
||
finished_count = len(self.finished_queue.download_items)
|
||
|
||
self.setTabText(0, f"Download Queue ({active_count})")
|
||
self.setTabText(1, f"Finished Downloads ({finished_count})")
|
||
|
||
# Also update the download manager stats if they exist
|
||
if hasattr(self.parent(), 'update_download_manager_stats'):
|
||
self.parent().update_download_manager_stats(active_count, finished_count)
|
||
|
||
def clear_completed_downloads(self):
|
||
"""Clear completed downloads from the finished queue"""
|
||
self.finished_queue.clear_completed_downloads()
|
||
self.update_tab_counts()
|
||
|
||
@property
|
||
def download_items(self):
|
||
"""Return all download items from active queue for compatibility"""
|
||
return self.active_queue.download_items
|
||
|
||
class DownloadsPage(QWidget):
|
||
def __init__(self, soulseek_client=None, parent=None):
|
||
super().__init__(parent)
|
||
self.soulseek_client = soulseek_client
|
||
self.search_thread = None
|
||
self.explore_thread = None # Track API exploration thread
|
||
self.session_thread = None # Track session info thread
|
||
self.download_threads = [] # Track active download threads
|
||
self.status_update_threads = [] # Track status update threads (CRITICAL FIX)
|
||
self.search_results = []
|
||
self.download_items = [] # Track download items for the queue
|
||
self.displayed_results = 0 # Track how many results are currently displayed
|
||
self.results_per_page = 15 # Show 15 results at a time
|
||
self.is_loading_more = False # Prevent multiple simultaneous loads
|
||
|
||
# Initialize audio player for streaming
|
||
self.audio_player = AudioPlayer(self)
|
||
self.audio_player.playback_finished.connect(self.on_audio_playback_finished)
|
||
self.audio_player.playback_error.connect(self.on_audio_playback_error)
|
||
self.currently_playing_button = None # Track which play button is active
|
||
self.currently_expanded_item = None # Track which item is currently expanded
|
||
|
||
# Download status polling timer
|
||
self.download_status_timer = QTimer()
|
||
self.download_status_timer.timeout.connect(self.update_download_status)
|
||
self.download_status_timer.start(2000) # Poll every 2 seconds
|
||
|
||
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setStyleSheet("""
|
||
DownloadsPage {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(25, 20, 20, 1.0),
|
||
stop:1 rgba(15, 15, 15, 1.0));
|
||
}
|
||
""")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
# Responsive margins that adapt to window size
|
||
main_layout.setContentsMargins(16, 12, 16, 16) # Reduced for tighter responsive feel
|
||
main_layout.setSpacing(12) # Consistent 12px spacing
|
||
|
||
# Elegant Header
|
||
header = self.create_elegant_header()
|
||
main_layout.addWidget(header)
|
||
|
||
# Main Content Area with responsive splitter
|
||
content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
content_splitter.setChildrenCollapsible(False) # Prevent panels from collapsing completely
|
||
|
||
# LEFT: Search & Results section
|
||
search_and_results = self.create_search_and_results_section()
|
||
search_and_results.setMinimumWidth(400) # Minimum width for usability
|
||
content_splitter.addWidget(search_and_results)
|
||
|
||
# RIGHT: Controls Panel
|
||
controls_panel = self.create_collapsible_controls_panel()
|
||
controls_panel.setMinimumWidth(280) # Minimum width for controls
|
||
controls_panel.setMaximumWidth(400) # Maximum width to prevent overgrowth
|
||
content_splitter.addWidget(controls_panel)
|
||
|
||
# Set initial splitter proportions (roughly 70/30)
|
||
content_splitter.setSizes([700, 300])
|
||
content_splitter.setStretchFactor(0, 1) # Search results gets priority for extra space
|
||
content_splitter.setStretchFactor(1, 0) # Controls panel stays fixed width when possible
|
||
|
||
main_layout.addWidget(content_splitter)
|
||
|
||
# Optional: Compact status bar at bottom
|
||
status_bar = self.create_compact_status_bar()
|
||
main_layout.addWidget(status_bar)
|
||
|
||
def create_elegant_header(self):
|
||
"""Create an elegant, minimal header"""
|
||
header = QFrame()
|
||
header.setMinimumHeight(80) # Minimum height, can grow if needed
|
||
header.setMaximumHeight(120) # Maximum to prevent overgrowth
|
||
header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
header.setStyleSheet("""
|
||
QFrame {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(header)
|
||
layout.setContentsMargins(16, 12, 16, 12) # Responsive padding consistent with main layout
|
||
layout.setSpacing(12) # Consistent spacing
|
||
|
||
# Icon and Title
|
||
title_section = QVBoxLayout()
|
||
title_section.setSpacing(4)
|
||
|
||
title_label = QLabel("🎵 Music Downloads")
|
||
title_label.setFont(QFont("Segoe UI", 26, QFont.Weight.Bold))
|
||
title_label.setStyleSheet("""
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
""")
|
||
|
||
subtitle_label = QLabel("Search, discover, and download high-quality music")
|
||
subtitle_label.setFont(QFont("Segoe UI", 13))
|
||
subtitle_label.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-weight: 300;
|
||
letter-spacing: 0.5px;
|
||
margin-top: 4px;
|
||
""")
|
||
|
||
title_section.addWidget(title_label)
|
||
title_section.addWidget(subtitle_label)
|
||
|
||
layout.addLayout(title_section)
|
||
layout.addStretch()
|
||
|
||
return header
|
||
|
||
def create_search_and_results_section(self):
|
||
"""Create the main search and results area - the star of the show"""
|
||
section = QFrame()
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 40, 40, 0.4),
|
||
stop:1 rgba(30, 30, 30, 0.6));
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(64, 64, 64, 0.3);
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(16, 12, 16, 12) # Responsive spacing consistent with main layout
|
||
layout.setSpacing(12) # Consistent 12px spacing
|
||
|
||
# Elegant Search Bar
|
||
search_container = self.create_elegant_search_bar()
|
||
layout.addWidget(search_container)
|
||
|
||
# Search Status with better visual feedback
|
||
self.search_status = QLabel("Ready to search • Enter artist, song, or album name")
|
||
self.search_status.setFont(QFont("Arial", 11))
|
||
self.search_status.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.7);
|
||
padding: 10px 18px;
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 rgba(29, 185, 84, 0.12),
|
||
stop:1 rgba(29, 185, 84, 0.08));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(29, 185, 84, 0.25);
|
||
""")
|
||
layout.addWidget(self.search_status)
|
||
|
||
# Search Results - The main attraction
|
||
results_container = QFrame()
|
||
results_container.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(20, 20, 20, 0.3);
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(64, 64, 64, 0.2);
|
||
}
|
||
""")
|
||
|
||
results_layout = QVBoxLayout(results_container)
|
||
results_layout.setContentsMargins(12, 8, 12, 12) # Tighter responsive spacing
|
||
results_layout.setSpacing(8) # Consistent small spacing for tight layout
|
||
|
||
# Results header
|
||
results_header = QLabel("Search Results")
|
||
results_header.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold))
|
||
results_header.setStyleSheet("""
|
||
color: rgba(255, 255, 255, 0.95);
|
||
font-weight: 600;
|
||
padding: 4px 8px;
|
||
""")
|
||
results_layout.addWidget(results_header)
|
||
|
||
# Scrollable results area - this gets ALL remaining space
|
||
self.search_results_scroll = QScrollArea()
|
||
self.search_results_scroll.setWidgetResizable(True)
|
||
self.search_results_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
self.search_results_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.search_results_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: rgba(64, 64, 64, 0.3);
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
margin: 0;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.8),
|
||
stop:1 rgba(29, 185, 84, 0.6));
|
||
border-radius: 4px;
|
||
min-height: 20px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: rgba(29, 185, 84, 1.0);
|
||
}
|
||
""")
|
||
|
||
self.search_results_widget = QWidget()
|
||
self.search_results_layout = QVBoxLayout(self.search_results_widget)
|
||
self.search_results_layout.setSpacing(8)
|
||
self.search_results_layout.setContentsMargins(4, 4, 4, 4)
|
||
self.search_results_layout.addStretch()
|
||
self.search_results_scroll.setWidget(self.search_results_widget)
|
||
|
||
# Connect scroll detection for automatic loading
|
||
scroll_bar = self.search_results_scroll.verticalScrollBar()
|
||
scroll_bar.valueChanged.connect(self.on_scroll_changed)
|
||
|
||
results_layout.addWidget(self.search_results_scroll)
|
||
layout.addWidget(results_container, 1) # This takes all remaining space
|
||
|
||
return section
|
||
|
||
def create_elegant_search_bar(self):
|
||
"""Create a beautiful, modern search bar"""
|
||
container = QFrame()
|
||
container.setFixedHeight(70)
|
||
container.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.8),
|
||
stop:1 rgba(40, 40, 40, 0.9));
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(container)
|
||
layout.setContentsMargins(16, 12, 16, 12) # Consistent responsive spacing
|
||
layout.setSpacing(12) # Consistent spacing throughout
|
||
|
||
# Search input with enhanced styling
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')")
|
||
self.search_input.setFixedHeight(40)
|
||
self.search_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # Responsive width
|
||
self.search_input.returnPressed.connect(self.perform_search)
|
||
self.search_input.setStyleSheet("""
|
||
QLineEdit {
|
||
background: rgba(60, 60, 60, 0.7);
|
||
border: 2px solid rgba(100, 100, 100, 0.3);
|
||
border-radius: 20px;
|
||
padding: 0 20px;
|
||
color: #ffffff;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
QLineEdit:focus {
|
||
border: 2px solid rgba(29, 185, 84, 0.8);
|
||
background: rgba(70, 70, 70, 0.9);
|
||
}
|
||
QLineEdit::placeholder {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
""")
|
||
|
||
# Enhanced search button
|
||
self.search_btn = QPushButton("🔍 Search")
|
||
self.search_btn.setFixedSize(120, 40)
|
||
self.search_btn.clicked.connect(self.perform_search)
|
||
self.search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(29, 185, 84, 1.0),
|
||
stop:1 rgba(24, 156, 71, 1.0));
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(30, 215, 96, 1.0),
|
||
stop:1 rgba(25, 180, 80, 1.0));
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(24, 156, 71, 1.0),
|
||
stop:1 rgba(20, 130, 60, 1.0));
|
||
}
|
||
QPushButton:disabled {
|
||
background: rgba(100, 100, 100, 0.3);
|
||
color: rgba(255, 255, 255, 0.3);
|
||
}
|
||
""")
|
||
|
||
layout.addWidget(self.search_input)
|
||
layout.addWidget(self.search_btn)
|
||
|
||
return container
|
||
|
||
def create_collapsible_controls_panel(self):
|
||
"""Create a compact, elegant controls panel"""
|
||
panel = QFrame()
|
||
panel.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 40, 40, 0.85),
|
||
stop:1 rgba(25, 25, 25, 0.95));
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(80, 80, 80, 0.4);
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(10, 10, 10, 10) # Consistent responsive spacing
|
||
layout.setSpacing(10) # Consistent spacing throughout
|
||
|
||
# Panel header
|
||
header = QLabel("Download Manager")
|
||
header.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
header.setStyleSheet("color: rgba(255, 255, 255, 0.9); padding: 6px 0; margin: 0;")
|
||
layout.addWidget(header)
|
||
|
||
# Quick stats with improved styling
|
||
stats_frame = QFrame()
|
||
stats_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.7),
|
||
stop:1 rgba(35, 35, 35, 0.8));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(80, 80, 80, 0.3);
|
||
}
|
||
""")
|
||
stats_layout = QVBoxLayout(stats_frame)
|
||
stats_layout.setContentsMargins(10, 8, 10, 8)
|
||
stats_layout.setSpacing(4)
|
||
|
||
self.active_downloads_label = QLabel("• Active Downloads: 0")
|
||
self.active_downloads_label.setFont(QFont("Arial", 9))
|
||
self.active_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); margin: 0; padding: 2px 0;")
|
||
|
||
self.finished_downloads_label = QLabel("• Finished Downloads: 0")
|
||
self.finished_downloads_label.setFont(QFont("Arial", 9))
|
||
self.finished_downloads_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); margin: 0; padding: 2px 0;")
|
||
|
||
stats_layout.addWidget(self.active_downloads_label)
|
||
stats_layout.addWidget(self.finished_downloads_label)
|
||
layout.addWidget(stats_frame)
|
||
|
||
# Control buttons with enhanced styling
|
||
controls_frame = QFrame()
|
||
controls_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(50, 50, 50, 0.6),
|
||
stop:1 rgba(30, 30, 30, 0.7));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(70, 70, 70, 0.4);
|
||
}
|
||
""")
|
||
controls_layout = QVBoxLayout(controls_frame)
|
||
controls_layout.setContentsMargins(10, 10, 10, 10)
|
||
controls_layout.setSpacing(6)
|
||
|
||
pause_btn = QPushButton("⏸️ Pause All")
|
||
pause_btn.setFixedHeight(28)
|
||
pause_btn.setStyleSheet(self._get_control_button_style("#ffa500"))
|
||
|
||
clear_btn = QPushButton("🗑️ Clear Completed")
|
||
clear_btn.setFixedHeight(28)
|
||
clear_btn.clicked.connect(self.clear_completed_downloads)
|
||
clear_btn.setStyleSheet(self._get_control_button_style("#e22134"))
|
||
|
||
controls_layout.addWidget(pause_btn)
|
||
controls_layout.addWidget(clear_btn)
|
||
layout.addWidget(controls_frame)
|
||
|
||
# Download Queue Section - Now with tabs for active and finished downloads
|
||
queue_container = QFrame()
|
||
queue_container.setStyleSheet("""
|
||
QFrame {
|
||
background: transparent;
|
||
border: none;
|
||
margin-top: 5px;
|
||
}
|
||
""")
|
||
queue_layout = QVBoxLayout(queue_container)
|
||
queue_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.download_queue = TabbedDownloadManager()
|
||
queue_layout.addWidget(self.download_queue)
|
||
layout.addWidget(queue_container)
|
||
|
||
# Initialize stats display
|
||
self.update_download_manager_stats(0, 0)
|
||
|
||
# Add stretch to push everything to top
|
||
layout.addStretch()
|
||
|
||
return panel
|
||
|
||
def update_download_manager_stats(self, active_count, finished_count):
|
||
"""Update the download manager statistics display"""
|
||
if hasattr(self, 'active_downloads_label'):
|
||
self.active_downloads_label.setText(f"• Active Downloads: {active_count}")
|
||
if hasattr(self, 'finished_downloads_label'):
|
||
self.finished_downloads_label.setText(f"• Finished Downloads: {finished_count}")
|
||
|
||
def create_compact_status_bar(self):
|
||
"""Create a minimal status bar"""
|
||
status_bar = QFrame()
|
||
status_bar.setFixedHeight(40)
|
||
status_bar.setStyleSheet("""
|
||
QFrame {
|
||
background: rgba(20, 20, 20, 0.8);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(64, 64, 64, 0.2);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(status_bar)
|
||
layout.setContentsMargins(16, 8, 16, 8)
|
||
layout.setSpacing(12)
|
||
|
||
connection_status = QLabel("🟢 slskd Connected")
|
||
connection_status.setFont(QFont("Arial", 10))
|
||
connection_status.setStyleSheet("color: rgba(29, 185, 84, 0.9);")
|
||
|
||
layout.addWidget(connection_status)
|
||
layout.addStretch()
|
||
|
||
download_path_info = QLabel(f"📁 Downloads: {self.soulseek_client.download_path if self.soulseek_client else './downloads'}")
|
||
download_path_info.setFont(QFont("Arial", 9))
|
||
download_path_info.setStyleSheet("color: rgba(255, 255, 255, 0.6);")
|
||
layout.addWidget(download_path_info)
|
||
|
||
return status_bar
|
||
|
||
def _get_control_button_style(self, color):
|
||
"""Get consistent button styling with improved aesthetics"""
|
||
return f"""
|
||
QPushButton {{
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (40,)},
|
||
stop:1 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (25,)});
|
||
border: 1px solid rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (80,)};
|
||
border-radius: 14px;
|
||
color: {color};
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 5px 10px;
|
||
text-align: center;
|
||
}}
|
||
QPushButton:hover {{
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (60,)},
|
||
stop:1 rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (40,)});
|
||
border: 1px solid {color};
|
||
color: #ffffff;
|
||
}}
|
||
QPushButton:pressed {{
|
||
background: rgba{tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + (80,)};
|
||
border: 1px solid {color};
|
||
}}
|
||
"""
|
||
|
||
def create_search_section(self):
|
||
section = QFrame()
|
||
section.setFixedHeight(350)
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
layout.setSpacing(15)
|
||
|
||
# Search header
|
||
search_header = QLabel("Search & Download")
|
||
search_header.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
search_header.setStyleSheet("color: #ffffff;")
|
||
|
||
# Search input and button
|
||
search_layout = QHBoxLayout()
|
||
|
||
self.search_input = QLineEdit()
|
||
self.search_input.setPlaceholderText("Search for music (e.g., 'Artist - Song Title')")
|
||
self.search_input.setFixedHeight(40)
|
||
self.search_input.returnPressed.connect(self.perform_search)
|
||
self.search_input.setStyleSheet("""
|
||
QLineEdit {
|
||
background: #404040;
|
||
border: 1px solid #606060;
|
||
border-radius: 20px;
|
||
padding: 0 15px;
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
}
|
||
QLineEdit:focus {
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
self.search_btn = QPushButton("🔍 Search")
|
||
self.search_btn.setFixedSize(100, 40)
|
||
self.search_btn.clicked.connect(self.perform_search)
|
||
self.search_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
QPushButton:disabled {
|
||
background: #404040;
|
||
color: #666666;
|
||
}
|
||
""")
|
||
|
||
search_layout.addWidget(self.search_input)
|
||
search_layout.addWidget(self.search_btn)
|
||
|
||
# Search status
|
||
self.search_status = QLabel("Enter a search term and click Search")
|
||
self.search_status.setFont(QFont("Arial", 10))
|
||
self.search_status.setStyleSheet("color: #b3b3b3;")
|
||
|
||
# Search results
|
||
self.search_results_scroll = QScrollArea()
|
||
self.search_results_scroll.setWidgetResizable(True)
|
||
self.search_results_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #404040;
|
||
width: 8px;
|
||
border-radius: 4px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
self.search_results_widget = QWidget()
|
||
self.search_results_layout = QVBoxLayout(self.search_results_widget)
|
||
self.search_results_layout.setSpacing(5)
|
||
|
||
# Just add stretch - no load more button needed with auto-scroll
|
||
self.search_results_layout.addStretch()
|
||
self.search_results_scroll.setWidget(self.search_results_widget)
|
||
|
||
layout.addWidget(search_header)
|
||
layout.addLayout(search_layout)
|
||
layout.addWidget(self.search_status)
|
||
layout.addWidget(self.search_results_scroll)
|
||
|
||
return section
|
||
|
||
def perform_search(self):
|
||
query = self.search_input.text().strip()
|
||
if not query:
|
||
self.update_search_status("⚠️ Please enter a search term", "#ffa500")
|
||
return
|
||
|
||
if not self.soulseek_client:
|
||
self.update_search_status("❌ Soulseek client not available", "#e22134")
|
||
return
|
||
|
||
# Stop any existing search
|
||
if self.search_thread and self.search_thread.isRunning():
|
||
self.search_thread.stop()
|
||
self.search_thread.wait(1000) # Wait up to 1 second
|
||
if self.search_thread.isRunning():
|
||
self.search_thread.terminate()
|
||
|
||
# Clear previous results and reset state
|
||
self.clear_search_results()
|
||
self.displayed_results = 0
|
||
self.is_loading_more = False
|
||
self.currently_expanded_item = None # Reset expanded state
|
||
|
||
# Enhanced searching state with animation
|
||
self.search_btn.setText("🔍 Searching...")
|
||
self.search_btn.setEnabled(False)
|
||
self.update_search_status(f"🔍 Searching for '{query}'... Results will appear as they are found", "#1db954")
|
||
|
||
# Start new search thread
|
||
self.search_thread = SearchThread(self.soulseek_client, query)
|
||
self.search_thread.search_completed.connect(self.on_search_completed)
|
||
self.search_thread.search_failed.connect(self.on_search_failed)
|
||
self.search_thread.search_progress.connect(self.on_search_progress)
|
||
self.search_thread.search_results_partial.connect(self.on_search_results_partial)
|
||
self.search_thread.finished.connect(self.on_search_thread_finished)
|
||
self.search_thread.start()
|
||
|
||
def update_search_status(self, message, color="#ffffff"):
|
||
"""Update search status with enhanced styling"""
|
||
self.search_status.setText(message)
|
||
|
||
if color == "#1db954": # Success/searching
|
||
bg_color = "rgba(29, 185, 84, 0.15)"
|
||
border_color = "rgba(29, 185, 84, 0.3)"
|
||
elif color == "#ffa500": # Warning
|
||
bg_color = "rgba(255, 165, 0, 0.15)"
|
||
border_color = "rgba(255, 165, 0, 0.3)"
|
||
elif color == "#e22134": # Error
|
||
bg_color = "rgba(226, 33, 52, 0.15)"
|
||
border_color = "rgba(226, 33, 52, 0.3)"
|
||
else: # Default
|
||
bg_color = "rgba(100, 100, 100, 0.1)"
|
||
border_color = "rgba(100, 100, 100, 0.2)"
|
||
|
||
self.search_status.setStyleSheet(f"""
|
||
color: {color};
|
||
padding: 12px 20px;
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||
stop:0 {bg_color},
|
||
stop:1 rgba(255, 255, 255, 0.02));
|
||
border-radius: 12px;
|
||
border: 1px solid {border_color};
|
||
""")
|
||
|
||
def on_search_thread_finished(self):
|
||
"""Clean up when search thread finishes"""
|
||
if self.search_thread:
|
||
self.search_thread.deleteLater()
|
||
self.search_thread = None
|
||
|
||
def clear_search_results(self):
|
||
# Remove all result items except the stretch
|
||
for i in reversed(range(self.search_results_layout.count())):
|
||
item = self.search_results_layout.itemAt(i)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
elif item.spacerItem():
|
||
continue # Keep the stretch spacer
|
||
else:
|
||
self.search_results_layout.removeItem(item)
|
||
|
||
def on_search_results_partial(self, new_results, total_count):
|
||
"""Handle progressive search results as they come in"""
|
||
# Sort new results by quality score and add to master list
|
||
new_results.sort(key=lambda x: x.quality_score, reverse=True)
|
||
|
||
# Add to master search results list (don't display all immediately)
|
||
if not hasattr(self, '_temp_search_results'):
|
||
self._temp_search_results = []
|
||
|
||
self._temp_search_results.extend(new_results)
|
||
|
||
# Only display up to the current page limit
|
||
remaining_slots = self.results_per_page - self.displayed_results
|
||
if remaining_slots > 0:
|
||
results_to_show = new_results[:remaining_slots]
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
for result in results_to_show:
|
||
result_item = SearchResultItem(result)
|
||
result_item.download_requested.connect(self.start_download)
|
||
result_item.stream_requested.connect(lambda search_result, item=result_item: self.start_stream(search_result, item))
|
||
result_item.expansion_requested.connect(self.handle_expansion_request)
|
||
# Insert before the stretch
|
||
insert_position = self.search_results_layout.count() - 1
|
||
self.search_results_layout.insertWidget(insert_position, result_item)
|
||
|
||
# Re-enable updates and force layout refresh
|
||
self.search_results_widget.setUpdatesEnabled(True)
|
||
self.search_results_widget.updateGeometry()
|
||
self.search_results_layout.update()
|
||
self.search_results_scroll.updateGeometry()
|
||
|
||
self.displayed_results += len(results_to_show)
|
||
|
||
# Update status message with real-time feedback
|
||
if self.displayed_results < self.results_per_page:
|
||
self.update_search_status(f"✨ Found {total_count} results so far • Showing first {self.displayed_results}", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✨ Found {total_count} results so far • Showing first {self.results_per_page} (scroll for more)", "#1db954")
|
||
|
||
def on_search_completed(self, results):
|
||
self.search_btn.setText("🔍 Search")
|
||
self.search_btn.setEnabled(True)
|
||
|
||
# Use temp results from progressive loading if available, otherwise use results
|
||
if hasattr(self, '_temp_search_results') and self._temp_search_results:
|
||
self.search_results = self._temp_search_results
|
||
del self._temp_search_results # Clean up temp storage
|
||
else:
|
||
self.search_results = results or []
|
||
|
||
total_results = len(self.search_results)
|
||
|
||
if total_results == 0:
|
||
if self.displayed_results == 0:
|
||
self.update_search_status("😔 No results found • Try a different search term or artist name", "#ffa500")
|
||
else:
|
||
self.update_search_status(f"✨ Search completed • Found {self.displayed_results} total results", "#1db954")
|
||
return
|
||
|
||
# Update status based on whether there are more results to load
|
||
if self.displayed_results < total_results:
|
||
remaining = total_results - self.displayed_results
|
||
self.update_search_status(f"✨ Found {total_results} results • Showing first {self.displayed_results} (scroll down for {remaining} more)", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✨ Search completed • Showing all {total_results} results", "#1db954")
|
||
|
||
# If we have no displayed results yet, show the first batch
|
||
if self.displayed_results == 0 and total_results > 0:
|
||
self.load_more_results()
|
||
|
||
def clear_search_results(self):
|
||
"""Clear all search result items from the layout"""
|
||
# Remove all SearchResultItem widgets (but keep stretch)
|
||
items_to_remove = []
|
||
for i in range(self.search_results_layout.count()):
|
||
item = self.search_results_layout.itemAt(i)
|
||
if item and item.widget() and isinstance(item.widget(), SearchResultItem):
|
||
items_to_remove.append(item.widget())
|
||
|
||
for widget in items_to_remove:
|
||
self.search_results_layout.removeWidget(widget)
|
||
widget.deleteLater()
|
||
|
||
def on_scroll_changed(self, value):
|
||
"""Handle scroll changes to implement lazy loading"""
|
||
if self.is_loading_more or not self.search_results:
|
||
return
|
||
|
||
scroll_bar = self.search_results_scroll.verticalScrollBar()
|
||
|
||
# Check if we're near the bottom (90% scrolled)
|
||
if scroll_bar.maximum() > 0:
|
||
scroll_percentage = value / scroll_bar.maximum()
|
||
|
||
if scroll_percentage >= 0.9 and self.displayed_results < len(self.search_results):
|
||
self.load_more_results()
|
||
|
||
def load_more_results(self):
|
||
"""Load the next batch of search results"""
|
||
if self.is_loading_more or not self.search_results:
|
||
return
|
||
|
||
self.is_loading_more = True
|
||
|
||
# Calculate how many more results to show
|
||
start_index = self.displayed_results
|
||
end_index = min(start_index + self.results_per_page, len(self.search_results))
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
# Add result items to UI
|
||
for i in range(start_index, end_index):
|
||
result = self.search_results[i]
|
||
result_item = SearchResultItem(result)
|
||
result_item.download_requested.connect(self.start_download)
|
||
result_item.stream_requested.connect(lambda search_result, item=result_item: self.start_stream(search_result, item))
|
||
result_item.expansion_requested.connect(self.handle_expansion_request)
|
||
# Insert before the stretch (which is always last)
|
||
insert_position = self.search_results_layout.count() - 1
|
||
self.search_results_layout.insertWidget(insert_position, result_item)
|
||
|
||
# Re-enable updates and force layout refresh
|
||
self.search_results_widget.setUpdatesEnabled(True)
|
||
self.search_results_widget.updateGeometry()
|
||
self.search_results_layout.update()
|
||
|
||
# Force scroll area to recognize new content size
|
||
self.search_results_scroll.updateGeometry()
|
||
|
||
# Update displayed count
|
||
self.displayed_results = end_index
|
||
|
||
# Update status
|
||
total_results = len(self.search_results)
|
||
if self.displayed_results >= total_results:
|
||
self.update_search_status(f"✨ Showing all {total_results} results", "#1db954")
|
||
else:
|
||
remaining = total_results - self.displayed_results
|
||
self.update_search_status(f"✨ Showing {self.displayed_results} of {total_results} results (scroll for {remaining} more)", "#1db954")
|
||
|
||
self.is_loading_more = False
|
||
|
||
def handle_expansion_request(self, requesting_item):
|
||
"""Handle accordion-style expansion where only one item can be expanded at a time"""
|
||
# If there's a currently expanded item and it's not the requesting item, collapse it
|
||
if self.currently_expanded_item and self.currently_expanded_item != requesting_item:
|
||
self.currently_expanded_item.set_expanded(False, animate=True)
|
||
|
||
# Toggle the requesting item
|
||
new_expanded_state = not requesting_item.is_expanded
|
||
requesting_item.set_expanded(new_expanded_state, animate=True)
|
||
|
||
# Update tracking
|
||
if new_expanded_state:
|
||
self.currently_expanded_item = requesting_item
|
||
else:
|
||
self.currently_expanded_item = None
|
||
|
||
def on_search_failed(self, error_msg):
|
||
self.search_btn.setText("🔍 Search")
|
||
self.search_btn.setEnabled(True)
|
||
self.update_search_status(f"❌ Search failed: {error_msg}", "#e22134")
|
||
|
||
def on_search_progress(self, message):
|
||
self.update_search_status(f"🔍 {message}", "#1db954")
|
||
|
||
def start_download(self, search_result):
|
||
"""Start downloading a search result using threaded approach"""
|
||
try:
|
||
# Extract track info for queue display
|
||
filename = search_result.filename
|
||
parts = filename.split(' - ')
|
||
if len(parts) >= 2:
|
||
artist = parts[0].strip()
|
||
title = ' - '.join(parts[1:]).strip()
|
||
# Remove file extension
|
||
if '.' in title:
|
||
title = '.'.join(title.split('.')[:-1])
|
||
else:
|
||
title = filename
|
||
artist = search_result.username
|
||
|
||
# Generate a unique download ID for tracking and cancellation
|
||
import time
|
||
download_id = f"{search_result.username}_{search_result.filename}_{int(time.time())}"
|
||
|
||
# Add to download queue immediately as "downloading"
|
||
download_item = self.download_queue.add_download_item(
|
||
title=title,
|
||
artist=artist,
|
||
status="downloading",
|
||
progress=0,
|
||
file_size=search_result.size,
|
||
download_id=download_id,
|
||
soulseek_client=self.soulseek_client
|
||
)
|
||
|
||
# Create and start download thread
|
||
download_thread = DownloadThread(self.soulseek_client, search_result, download_item)
|
||
download_thread.download_completed.connect(self.on_download_completed, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.download_failed.connect(self.on_download_failed, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.download_progress.connect(self.on_download_progress, Qt.ConnectionType.QueuedConnection)
|
||
download_thread.finished.connect(
|
||
functools.partial(self.on_download_thread_finished, download_thread),
|
||
Qt.ConnectionType.QueuedConnection
|
||
)
|
||
|
||
# Track the thread
|
||
self.download_threads.append(download_thread)
|
||
|
||
# Start the download
|
||
download_thread.start()
|
||
|
||
# Download started - feedback will appear in download queue
|
||
|
||
except Exception as e:
|
||
print(f"Failed to start download: {str(e)}")
|
||
|
||
def start_stream(self, search_result, result_item=None):
|
||
"""Start streaming a search result using StreamingThread"""
|
||
try:
|
||
print(f"Starting stream: {search_result.filename} from {search_result.username}")
|
||
|
||
# Stop any currently playing audio and reset previous button
|
||
if self.currently_playing_button:
|
||
self.audio_player.stop_playback()
|
||
self.currently_playing_button.reset_play_state()
|
||
|
||
# Track the new currently playing button
|
||
self.currently_playing_button = result_item
|
||
|
||
# Check if file is a valid audio type
|
||
audio_extensions = ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav']
|
||
filename_lower = search_result.filename.lower()
|
||
|
||
is_audio = any(filename_lower.endswith(ext) for ext in audio_extensions)
|
||
|
||
if is_audio:
|
||
print(f"✓ Streaming audio file: {search_result.filename}")
|
||
print(f" Quality: {search_result.quality}")
|
||
print(f" Size: {search_result.size // (1024*1024)}MB")
|
||
print(f" User: {search_result.username}")
|
||
|
||
# Create and start streaming thread
|
||
streaming_thread = StreamingThread(self.soulseek_client, search_result)
|
||
streaming_thread.streaming_started.connect(self.on_streaming_started, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.streaming_finished.connect(self.on_streaming_finished, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.streaming_failed.connect(self.on_streaming_failed, Qt.ConnectionType.QueuedConnection)
|
||
streaming_thread.finished.connect(
|
||
functools.partial(self.on_streaming_thread_finished, streaming_thread),
|
||
Qt.ConnectionType.QueuedConnection
|
||
)
|
||
|
||
# Track the streaming thread
|
||
if not hasattr(self, 'streaming_threads'):
|
||
self.streaming_threads = []
|
||
self.streaming_threads.append(streaming_thread)
|
||
|
||
# Start the streaming
|
||
streaming_thread.start()
|
||
|
||
else:
|
||
print(f"✗ Cannot stream non-audio file: {search_result.filename}")
|
||
|
||
except Exception as e:
|
||
print(f"Failed to start stream: {str(e)}")
|
||
|
||
def on_streaming_started(self, message, search_result):
|
||
"""Handle streaming start"""
|
||
print(f"Streaming started: {message}")
|
||
# Set button to loading state while file is being prepared
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.set_loading_state()
|
||
|
||
def on_streaming_finished(self, message, search_result):
|
||
"""Handle streaming completion - start actual audio playback"""
|
||
print(f"Streaming finished: {message}")
|
||
|
||
try:
|
||
# Find the stream file in the Stream folder
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up from ui/pages/
|
||
stream_folder = os.path.join(project_root, 'Stream')
|
||
|
||
# Find the current stream file
|
||
stream_file = None
|
||
for filename in os.listdir(stream_folder):
|
||
if filename.startswith('current_stream') and os.path.isfile(os.path.join(stream_folder, filename)):
|
||
stream_file = os.path.join(stream_folder, filename)
|
||
break
|
||
|
||
if stream_file and os.path.exists(stream_file):
|
||
# Start audio playback
|
||
success = self.audio_player.play_file(stream_file)
|
||
if success:
|
||
print(f"🎵 Started audio playback: {os.path.basename(stream_file)}")
|
||
# Set button to playing state
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.set_playing_state()
|
||
else:
|
||
print(f"❌ Failed to start audio playback")
|
||
# Reset button on failure
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
else:
|
||
print(f"❌ Stream file not found in {stream_folder}")
|
||
# Reset button on failure
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error starting audio playback: {e}")
|
||
# Reset button on error
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
|
||
def on_streaming_failed(self, error_msg, search_result):
|
||
"""Handle streaming failure"""
|
||
print(f"Streaming failed: {error_msg}")
|
||
# Reset any play button that might be waiting
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
|
||
def on_streaming_thread_finished(self, thread):
|
||
"""Clean up when streaming thread finishes"""
|
||
try:
|
||
if hasattr(self, 'streaming_threads') and thread in self.streaming_threads:
|
||
self.streaming_threads.remove(thread)
|
||
|
||
# Disconnect all signals to prevent stale connections
|
||
try:
|
||
thread.streaming_started.disconnect()
|
||
thread.streaming_finished.disconnect()
|
||
thread.streaming_failed.disconnect()
|
||
thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
# Ensure thread is properly stopped before deletion
|
||
if thread.isRunning():
|
||
thread.stop()
|
||
thread.wait(1000) # Wait up to 1 second
|
||
|
||
# Use QTimer.singleShot for delayed cleanup
|
||
QTimer.singleShot(100, thread.deleteLater)
|
||
|
||
except Exception as e:
|
||
print(f"Error cleaning up finished streaming thread: {e}")
|
||
|
||
def on_audio_playback_finished(self):
|
||
"""Handle when audio playback finishes"""
|
||
print("🎵 Audio playback completed")
|
||
# Reset the play button to play state
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
|
||
def on_audio_playback_error(self, error_msg):
|
||
"""Handle audio playback errors"""
|
||
print(f"❌ Audio playback error: {error_msg}")
|
||
# Reset the play button to play state
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
|
||
def on_download_completed(self, message, download_item):
|
||
"""Handle successful download start"""
|
||
print(f"Download success: {message}")
|
||
# Update download item status to completed
|
||
download_item.status = "completed"
|
||
download_item.progress = 100
|
||
# TODO: Add actual file path from download result
|
||
|
||
def on_download_failed(self, error_msg, download_item):
|
||
"""Handle download failure"""
|
||
print(f"Download failed: {error_msg}")
|
||
# Update download item status to failed
|
||
download_item.status = "failed"
|
||
download_item.progress = 0
|
||
# Error logged to console for debugging
|
||
|
||
def on_download_progress(self, message, download_item):
|
||
"""Handle download progress updates"""
|
||
print(f"Download progress: {message}")
|
||
# Extract progress percentage if available from message
|
||
# For now just show as downloading
|
||
download_item.status = "downloading"
|
||
|
||
def on_download_thread_finished(self, thread):
|
||
"""Clean up when download thread finishes"""
|
||
try:
|
||
if thread in self.download_threads:
|
||
self.download_threads.remove(thread)
|
||
|
||
# Disconnect all signals to prevent stale connections
|
||
try:
|
||
thread.download_completed.disconnect()
|
||
thread.download_failed.disconnect()
|
||
thread.download_progress.disconnect()
|
||
thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
# Ensure thread is properly stopped before deletion
|
||
if thread.isRunning():
|
||
thread.stop()
|
||
thread.wait(1000) # Wait up to 1 second
|
||
|
||
# Use QTimer.singleShot for delayed cleanup to ensure signal processing is complete
|
||
QTimer.singleShot(100, thread.deleteLater)
|
||
|
||
except Exception as e:
|
||
print(f"Error cleaning up finished download thread: {e}")
|
||
|
||
def clear_completed_downloads(self):
|
||
"""Clear completed downloads from the queue"""
|
||
self.download_queue.clear_completed_downloads()
|
||
|
||
def update_download_status(self):
|
||
"""Poll slskd API for download status updates (QTimer callback) - FIXED VERSION"""
|
||
if not self.soulseek_client or not self.download_queue.download_items:
|
||
return
|
||
|
||
# CRITICAL FIX: Use tracked thread instead of anonymous thread
|
||
def handle_status_update(downloads):
|
||
"""Handle the download status update in the main thread - IMPROVED MATCHING"""
|
||
try:
|
||
print(f"[DEBUG] Processing {len(downloads)} downloads from API")
|
||
|
||
for download_item in self.download_queue.download_items:
|
||
# IMPROVED: Try multiple matching strategies
|
||
queue_title = download_item.title.lower()
|
||
queue_artist = download_item.artist.lower()
|
||
|
||
print(f"[DEBUG] Looking for: '{queue_artist} - {queue_title}'")
|
||
|
||
found_match = False
|
||
for download in downloads:
|
||
api_filename = download.filename.lower()
|
||
print(f"[DEBUG] Checking against: '{download.filename}'")
|
||
|
||
# Strategy 1: Check if title is in the filename
|
||
title_match = queue_title in api_filename
|
||
|
||
# Strategy 2: Check if artist is in the filename
|
||
artist_match = queue_artist in api_filename
|
||
|
||
# Strategy 3: Reverse check - see if any part of queue item is in API filename
|
||
combined_check = f"{queue_artist} - {queue_title}" in api_filename
|
||
|
||
if title_match or artist_match or combined_check:
|
||
print(f"[DEBUG] ✓ MATCH FOUND: {download.filename}")
|
||
print(f"[DEBUG] Status: {download.state}, Progress: {download.progress}%")
|
||
|
||
# Update the UI item with real data
|
||
download_item.update_status(
|
||
status=download.state,
|
||
progress=int(download.progress),
|
||
download_speed=download.speed,
|
||
file_path=download.filename
|
||
)
|
||
|
||
# AUTO-MOVE: Check if download is finished and move to finished tab
|
||
finished_states = ["completed", "cancelled", "completed, succeeded", "completed, cancelled"]
|
||
if download.state.lower() in [state.lower() for state in finished_states]:
|
||
print(f"[DEBUG] Moving finished download to finished tab: {download_item.title}")
|
||
self.download_queue.move_to_finished(download_item)
|
||
|
||
found_match = True
|
||
break
|
||
|
||
if not found_match:
|
||
print(f"[DEBUG] ✗ NO MATCH for: '{queue_artist} - {queue_title}'")
|
||
|
||
except Exception as e:
|
||
print(f"Error updating download UI: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
def on_status_thread_finished(thread):
|
||
"""Clean up status thread when finished"""
|
||
try:
|
||
if thread in self.status_update_threads:
|
||
self.status_update_threads.remove(thread)
|
||
thread.deleteLater()
|
||
except Exception as e:
|
||
print(f"Error cleaning up status thread: {e}")
|
||
|
||
# CRITICAL FIX: Create tracked status update thread
|
||
status_thread = TrackedStatusUpdateThread(self.soulseek_client, self)
|
||
status_thread.status_updated.connect(handle_status_update, Qt.ConnectionType.QueuedConnection)
|
||
status_thread.finished.connect(
|
||
functools.partial(on_status_thread_finished, status_thread),
|
||
Qt.ConnectionType.QueuedConnection
|
||
)
|
||
|
||
# CRITICAL FIX: Track the thread for proper cleanup
|
||
self.status_update_threads.append(status_thread)
|
||
status_thread.start()
|
||
|
||
|
||
def cleanup_all_threads(self):
|
||
"""Stop and cleanup all active threads"""
|
||
try:
|
||
# Stop download status timer first
|
||
if hasattr(self, 'download_status_timer'):
|
||
self.download_status_timer.stop()
|
||
|
||
# Stop search thread
|
||
if self.search_thread and self.search_thread.isRunning():
|
||
self.search_thread.stop()
|
||
self.search_thread.wait(2000) # Wait up to 2 seconds
|
||
if self.search_thread.isRunning():
|
||
self.search_thread.terminate()
|
||
self.search_thread.wait(1000)
|
||
self.search_thread.deleteLater()
|
||
self.search_thread = None
|
||
|
||
# Stop explore thread
|
||
if self.explore_thread and self.explore_thread.isRunning():
|
||
self.explore_thread.stop()
|
||
self.explore_thread.wait(2000) # Wait up to 2 seconds
|
||
if self.explore_thread.isRunning():
|
||
self.explore_thread.terminate()
|
||
self.explore_thread.wait(1000)
|
||
self.explore_thread.deleteLater()
|
||
self.explore_thread = None
|
||
|
||
# Stop session thread
|
||
if self.session_thread and self.session_thread.isRunning():
|
||
self.session_thread.stop()
|
||
self.session_thread.wait(2000) # Wait up to 2 seconds
|
||
if self.session_thread.isRunning():
|
||
self.session_thread.terminate()
|
||
self.session_thread.wait(1000)
|
||
self.session_thread.deleteLater()
|
||
self.session_thread = None
|
||
|
||
# CRITICAL FIX: Stop all status update threads
|
||
for status_thread in self.status_update_threads[:]: # Copy list to avoid modification during iteration
|
||
try:
|
||
# Disconnect signals first
|
||
try:
|
||
status_thread.status_updated.disconnect()
|
||
status_thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
if status_thread.isRunning():
|
||
status_thread.stop()
|
||
status_thread.wait(2000) # Wait up to 2 seconds
|
||
if status_thread.isRunning():
|
||
status_thread.terminate()
|
||
status_thread.wait(1000)
|
||
status_thread.deleteLater()
|
||
except Exception as e:
|
||
print(f"Error cleaning up status update thread: {e}")
|
||
|
||
self.status_update_threads.clear()
|
||
|
||
# Stop all download threads with proper cleanup
|
||
for download_thread in self.download_threads[:]: # Copy list to avoid modification during iteration
|
||
try:
|
||
# Disconnect signals first
|
||
try:
|
||
download_thread.download_completed.disconnect()
|
||
download_thread.download_failed.disconnect()
|
||
download_thread.download_progress.disconnect()
|
||
download_thread.finished.disconnect()
|
||
except Exception:
|
||
pass # Ignore if signals are already disconnected
|
||
|
||
if download_thread.isRunning():
|
||
download_thread.stop()
|
||
download_thread.wait(2000) # Wait up to 2 seconds
|
||
if download_thread.isRunning():
|
||
download_thread.terminate()
|
||
download_thread.wait(1000)
|
||
download_thread.deleteLater()
|
||
except Exception as e:
|
||
print(f"Error cleaning up download thread: {e}")
|
||
|
||
self.download_threads.clear()
|
||
|
||
except Exception as e:
|
||
print(f"Error during thread cleanup: {e}")
|
||
|
||
def closeEvent(self, event):
|
||
"""Handle widget close event"""
|
||
self.cleanup_all_threads()
|
||
super().closeEvent(event)
|
||
|
||
def __del__(self):
|
||
"""Destructor - ensure cleanup happens even if closeEvent isn't called"""
|
||
try:
|
||
self.cleanup_all_threads()
|
||
except:
|
||
pass # Ignore errors during destruction
|
||
|
||
def create_controls_section(self):
|
||
section = QWidget()
|
||
layout = QVBoxLayout(section)
|
||
layout.setSpacing(20)
|
||
|
||
# Download controls
|
||
controls_frame = QFrame()
|
||
controls_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
controls_layout = QVBoxLayout(controls_frame)
|
||
controls_layout.setContentsMargins(20, 20, 20, 20)
|
||
controls_layout.setSpacing(15)
|
||
|
||
# Controls title
|
||
controls_title = QLabel("Download Controls")
|
||
controls_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
controls_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Pause/Resume button
|
||
pause_btn = QPushButton("⏸️ Pause Downloads")
|
||
pause_btn.setFixedHeight(40)
|
||
pause_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 20px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
""")
|
||
|
||
# Clear completed button
|
||
clear_btn = QPushButton("🗑️ Clear Completed")
|
||
clear_btn.setFixedHeight(35)
|
||
clear_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: transparent;
|
||
border: 1px solid #e22134;
|
||
border-radius: 17px;
|
||
color: #e22134;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(226, 33, 52, 0.1);
|
||
}
|
||
""")
|
||
|
||
controls_layout.addWidget(controls_title)
|
||
controls_layout.addWidget(pause_btn)
|
||
controls_layout.addWidget(clear_btn)
|
||
|
||
# Download stats
|
||
stats_frame = QFrame()
|
||
stats_frame.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
stats_layout = QVBoxLayout(stats_frame)
|
||
stats_layout.setContentsMargins(20, 20, 20, 20)
|
||
stats_layout.setSpacing(15)
|
||
|
||
# Stats title
|
||
stats_title = QLabel("Download Statistics")
|
||
stats_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
stats_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Stats items
|
||
stats_items = [
|
||
("Total Downloads", "247"),
|
||
("Completed", "238"),
|
||
("Failed", "4"),
|
||
("In Progress", "2"),
|
||
("Queued", "3")
|
||
]
|
||
|
||
stats_layout.addWidget(stats_title)
|
||
|
||
for label, value in stats_items:
|
||
item_layout = QHBoxLayout()
|
||
|
||
label_widget = QLabel(label)
|
||
label_widget.setFont(QFont("Arial", 11))
|
||
label_widget.setStyleSheet("color: #b3b3b3;")
|
||
|
||
value_widget = QLabel(value)
|
||
value_widget.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||
value_widget.setStyleSheet("color: #ffffff;")
|
||
|
||
item_layout.addWidget(label_widget)
|
||
item_layout.addStretch()
|
||
item_layout.addWidget(value_widget)
|
||
|
||
stats_layout.addLayout(item_layout)
|
||
|
||
layout.addWidget(controls_frame)
|
||
layout.addWidget(stats_frame)
|
||
layout.addStretch()
|
||
|
||
return section
|
||
|
||
def create_missing_tracks_section(self):
|
||
section = QFrame()
|
||
section.setFixedHeight(250)
|
||
section.setStyleSheet("""
|
||
QFrame {
|
||
background: #282828;
|
||
border-radius: 8px;
|
||
border: 1px solid #404040;
|
||
}
|
||
""")
|
||
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
layout.setSpacing(15)
|
||
|
||
# Header
|
||
header_layout = QHBoxLayout()
|
||
|
||
title_label = QLabel("Missing Tracks")
|
||
title_label.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
||
title_label.setStyleSheet("color: #ffffff;")
|
||
|
||
count_label = QLabel("23 tracks")
|
||
count_label.setFont(QFont("Arial", 11))
|
||
count_label.setStyleSheet("color: #b3b3b3;")
|
||
|
||
download_all_btn = QPushButton("📥 Download All")
|
||
download_all_btn.setFixedSize(120, 35)
|
||
download_all_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: #1db954;
|
||
border: none;
|
||
border-radius: 17px;
|
||
color: #000000;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1ed760;
|
||
}
|
||
""")
|
||
|
||
header_layout.addWidget(title_label)
|
||
header_layout.addWidget(count_label)
|
||
header_layout.addStretch()
|
||
header_layout.addWidget(download_all_btn)
|
||
|
||
# Missing tracks scroll area
|
||
missing_scroll = QScrollArea()
|
||
missing_scroll.setWidgetResizable(True)
|
||
missing_scroll.setStyleSheet("""
|
||
QScrollArea {
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
QScrollBar:vertical {
|
||
background: #404040;
|
||
width: 6px;
|
||
border-radius: 3px;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #1db954;
|
||
border-radius: 3px;
|
||
}
|
||
""")
|
||
|
||
missing_widget = QWidget()
|
||
missing_layout = QVBoxLayout(missing_widget)
|
||
missing_layout.setSpacing(8)
|
||
missing_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Sample missing tracks with playlist info
|
||
missing_tracks = [
|
||
("Song Title 1", "Artist Name 1", "Liked Songs"),
|
||
("Another Track", "Different Artist", "Road Trip Mix"),
|
||
("Cool Song", "Band Name", "Workout Playlist"),
|
||
("Missing Hit", "Popular Artist", "Discover Weekly"),
|
||
("Rare Track", "Indie Artist", "Chill Vibes")
|
||
]
|
||
|
||
for track_title, artist, playlist in missing_tracks:
|
||
track_item = self.create_missing_track_item(track_title, artist, playlist)
|
||
missing_layout.addWidget(track_item)
|
||
|
||
missing_layout.addStretch()
|
||
missing_scroll.setWidget(missing_widget)
|
||
|
||
layout.addLayout(header_layout)
|
||
layout.addWidget(missing_scroll)
|
||
|
||
return section
|
||
|
||
def create_missing_track_item(self, track_title: str, artist: str, playlist: str):
|
||
item = QFrame()
|
||
item.setFixedHeight(45)
|
||
item.setStyleSheet("""
|
||
QFrame {
|
||
background: #333333;
|
||
border-radius: 6px;
|
||
border: 1px solid #404040;
|
||
}
|
||
QFrame:hover {
|
||
background: #3a3a3a;
|
||
border: 1px solid #1db954;
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(item)
|
||
layout.setContentsMargins(12, 8, 12, 8)
|
||
layout.setSpacing(10)
|
||
|
||
# Track info
|
||
info_layout = QVBoxLayout()
|
||
info_layout.setSpacing(2)
|
||
|
||
track_label = QLabel(f"{track_title} - {artist}")
|
||
track_label.setFont(QFont("Arial", 10, QFont.Weight.Medium))
|
||
track_label.setStyleSheet("color: #ffffff;")
|
||
|
||
playlist_label = QLabel(f"from: {playlist}")
|
||
playlist_label.setFont(QFont("Arial", 9))
|
||
playlist_label.setStyleSheet("color: #1db954;")
|
||
|
||
info_layout.addWidget(track_label)
|
||
info_layout.addWidget(playlist_label)
|
||
|
||
# Download button
|
||
download_btn = QPushButton("📥")
|
||
download_btn.setFixedSize(30, 30)
|
||
download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
border: 1px solid #1db954;
|
||
border-radius: 15px;
|
||
color: #1db954;
|
||
font-size: 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: #1db954;
|
||
color: #000000;
|
||
}
|
||
""")
|
||
|
||
layout.addLayout(info_layout)
|
||
layout.addStretch()
|
||
layout.addWidget(download_btn)
|
||
|
||
return item |