5117 lines
No EOL
219 KiB
Python
5117 lines
No EOL
219 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
|
||
|
||
# Import the new search result classes
|
||
from core.soulseek_client import TrackResult, AlbumResult
|
||
|
||
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)
|
||
self.playbackStateChanged.connect(self._on_playback_state_changed)
|
||
|
||
# Track current file
|
||
self.current_file_path = None
|
||
self.is_playing = False
|
||
|
||
def _on_playback_state_changed(self, state):
|
||
"""Keep is_playing flag synchronized with actual playback state"""
|
||
from PyQt6.QtMultimedia import QMediaPlayer
|
||
state_names = {
|
||
QMediaPlayer.PlaybackState.StoppedState: "STOPPED",
|
||
QMediaPlayer.PlaybackState.PlayingState: "PLAYING",
|
||
QMediaPlayer.PlaybackState.PausedState: "PAUSED"
|
||
}
|
||
print(f"🎵 AudioPlayer state changed to: {state_names.get(state, 'UNKNOWN')}")
|
||
self.is_playing = (state == QMediaPlayer.PlaybackState.PlayingState)
|
||
|
||
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()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
|
||
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"""
|
||
current_state = self.playbackState()
|
||
print(f"🔄 toggle_playback() - Current state: {current_state}")
|
||
print(f"🔄 toggle_playback() - Current source: {self.source().toString()}")
|
||
|
||
if current_state == QMediaPlayer.PlaybackState.PlayingState:
|
||
print("⏸️ AudioPlayer: Pausing playback")
|
||
self.pause()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
return False # Now paused
|
||
else:
|
||
print("▶️ AudioPlayer: Attempting to resume/play")
|
||
|
||
# Check if we have a valid source to play
|
||
if not self.source().isValid() and self.current_file_path:
|
||
print(f"🔧 AudioPlayer: No source set, restoring from: {self.current_file_path}")
|
||
self.setSource(QUrl.fromLocalFile(self.current_file_path))
|
||
|
||
self.play()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
return True # Now playing
|
||
|
||
def stop_playback(self):
|
||
"""Stop playback and reset"""
|
||
print("⏹️ AudioPlayer: stop_playback() called")
|
||
self.stop()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
self.release_file()
|
||
|
||
def release_file(self, clear_file_path=True):
|
||
"""Release the current file handle by clearing the media source
|
||
|
||
Args:
|
||
clear_file_path (bool): Whether to clear the stored file path.
|
||
Set to False to keep the path for potential resuming.
|
||
"""
|
||
print(f"🔓 AudioPlayer: release_file() called - clearing source: {self.source().toString()}")
|
||
self.setSource(QUrl()) # Clear the media source to release file handle
|
||
if clear_file_path:
|
||
self.current_file_path = None
|
||
print("🔓 Released audio file handle")
|
||
|
||
def _on_media_status_changed(self, status):
|
||
"""Handle media status changes"""
|
||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||
print("🎵 Playback finished")
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
self.playback_finished.emit()
|
||
elif status == QMediaPlayer.MediaStatus.InvalidMedia:
|
||
error_msg = "Invalid media file or unsupported format"
|
||
print(f"❌ {error_msg}")
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
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}")
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
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 TransferStatusThread(QThread):
|
||
"""Thread for fetching real-time download transfer status from slskd API"""
|
||
transfer_status_completed = pyqtSignal(object) # Transfer data from API
|
||
transfer_status_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 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 transfer status data from /api/v0/transfers/downloads
|
||
transfer_data = loop.run_until_complete(self._get_transfer_status())
|
||
|
||
# Only emit if not stopped
|
||
if not self._stop_requested:
|
||
self.transfer_status_completed.emit(transfer_data or [])
|
||
|
||
except Exception as e:
|
||
if not self._stop_requested:
|
||
self.transfer_status_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 transfer status event loop: {e}")
|
||
|
||
async def _get_transfer_status(self):
|
||
"""Get the transfer status from slskd API"""
|
||
try:
|
||
# Use the soulseek client's _make_request method to get transfer data
|
||
response_data = await self.soulseek_client._make_request('GET', 'transfers/downloads')
|
||
return response_data
|
||
except Exception as e:
|
||
print(f"Error fetching transfer status: {e}")
|
||
return []
|
||
|
||
def stop(self):
|
||
"""Stop the transfer status gathering gracefully"""
|
||
self._stop_requested = True
|
||
|
||
class SearchThread(QThread):
|
||
search_completed = pyqtSignal(object) # Tuple of (tracks, albums) or list for backward compatibility
|
||
search_failed = pyqtSignal(str) # Error message
|
||
search_progress = pyqtSignal(str) # Progress message
|
||
search_results_partial = pyqtSignal(object, object, int) # tracks, albums, response count
|
||
|
||
def __init__(self, soulseek_client, query):
|
||
super().__init__()
|
||
self.soulseek_client = soulseek_client
|
||
self.query = query
|
||
self._stop_requested = False
|
||
|
||
def progress_callback(self, tracks, albums, response_count):
|
||
"""Callback function for progressive search results"""
|
||
if not self._stop_requested:
|
||
# Emit live results immediately
|
||
self.search_results_partial.emit(tracks, albums, response_count)
|
||
# Update progress message with current count
|
||
self.search_progress.emit(f"Found {len(tracks)} tracks, {len(albums)} albums from {response_count} responses")
|
||
|
||
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 proper tuple format
|
||
# results should be a tuple (tracks, albums) from the search client
|
||
self.search_completed.emit(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 with original filename
|
||
original_filename = os.path.basename(found_file)
|
||
stream_path = os.path.join(stream_folder, original_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 TrackItem(QFrame):
|
||
"""Individual track item within an album"""
|
||
track_download_requested = pyqtSignal(object) # TrackResult object
|
||
track_stream_requested = pyqtSignal(object) # TrackResult object
|
||
|
||
def __init__(self, track_result, parent=None):
|
||
super().__init__(parent)
|
||
self.track_result = track_result
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(50)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
self.setStyleSheet("""
|
||
TrackItem {
|
||
background: rgba(40, 40, 40, 0.5);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(60, 60, 60, 0.3);
|
||
margin: 2px 8px;
|
||
}
|
||
TrackItem:hover {
|
||
background: rgba(50, 50, 50, 0.7);
|
||
border: 1px solid rgba(29, 185, 84, 0.5);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(12, 8, 12, 8)
|
||
layout.setSpacing(12)
|
||
|
||
# Track info
|
||
track_info = QVBoxLayout()
|
||
track_info.setSpacing(2)
|
||
|
||
# Track title
|
||
title = QLabel(self.track_result.title or "Unknown Title")
|
||
title.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||
title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Track details
|
||
details = []
|
||
if self.track_result.track_number:
|
||
details.append(f"#{self.track_result.track_number:02d}")
|
||
details.append(self.track_result.quality.upper())
|
||
if self.track_result.bitrate:
|
||
details.append(f"{self.track_result.bitrate}kbps")
|
||
details.append(f"{self.track_result.size // (1024*1024)}MB")
|
||
|
||
details_text = " • ".join(details)
|
||
track_details = QLabel(details_text)
|
||
track_details.setFont(QFont("Arial", 9))
|
||
track_details.setStyleSheet("color: rgba(179, 179, 179, 0.8);")
|
||
|
||
track_info.addWidget(title)
|
||
track_info.addWidget(track_details)
|
||
|
||
# Control buttons
|
||
button_layout = QHBoxLayout()
|
||
button_layout.setSpacing(8)
|
||
|
||
# Play button
|
||
play_btn = QPushButton("▶️")
|
||
play_btn.setFixedSize(32, 32)
|
||
play_btn.clicked.connect(self.request_stream)
|
||
play_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(29, 185, 84, 0.8);
|
||
border: none;
|
||
border-radius: 16px;
|
||
color: #000000;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(30, 215, 96, 1.0);
|
||
}
|
||
""")
|
||
|
||
# Download button
|
||
download_btn = QPushButton("⬇️")
|
||
download_btn.setFixedSize(32, 32)
|
||
download_btn.clicked.connect(self.request_download)
|
||
download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(64, 64, 64, 0.8);
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 16px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
}
|
||
""")
|
||
|
||
button_layout.addWidget(play_btn)
|
||
button_layout.addWidget(download_btn)
|
||
|
||
# Store button references for state management
|
||
self.play_btn = play_btn
|
||
self.download_btn = download_btn
|
||
|
||
# Assembly
|
||
layout.addLayout(track_info, 1)
|
||
layout.addLayout(button_layout)
|
||
|
||
def request_stream(self):
|
||
"""Request streaming of this track"""
|
||
self.track_stream_requested.emit(self.track_result)
|
||
|
||
def request_download(self):
|
||
"""Request download of this track"""
|
||
self.track_download_requested.emit(self.track_result)
|
||
|
||
def set_loading_state(self):
|
||
"""Set play button to loading state"""
|
||
self.play_btn.setText("⏳")
|
||
self.play_btn.setEnabled(False)
|
||
|
||
def set_download_queued_state(self):
|
||
"""Set download button to queued state (disabled, shows queued)"""
|
||
self.download_btn.setText("⏳")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(100, 100, 100, 0.5);
|
||
border: 1px solid rgba(150, 150, 150, 0.3);
|
||
border-radius: 16px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
|
||
def set_download_downloading_state(self):
|
||
"""Set download button to downloading state"""
|
||
self.download_btn.setText("📥")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(29, 185, 84, 0.3);
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 16px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
|
||
def set_download_completed_state(self):
|
||
"""Set download button to completed state"""
|
||
self.download_btn.setText("✅")
|
||
self.download_btn.setEnabled(False)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(40, 167, 69, 0.3);
|
||
border: 1px solid rgba(40, 167, 69, 0.6);
|
||
border-radius: 16px;
|
||
color: #28a745;
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
|
||
def reset_download_state(self):
|
||
"""Reset download button to default state"""
|
||
self.download_btn.setText("⬇️")
|
||
self.download_btn.setEnabled(True)
|
||
self.download_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(64, 64, 64, 0.8);
|
||
border: 1px solid rgba(29, 185, 84, 0.6);
|
||
border-radius: 16px;
|
||
color: #1db954;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
}
|
||
""")
|
||
|
||
def set_playing_state(self):
|
||
"""Set play button to playing/pause state"""
|
||
self.play_btn.setText("⏸️")
|
||
self.play_btn.setEnabled(True)
|
||
|
||
def reset_play_state(self):
|
||
"""Reset play button to default state"""
|
||
self.play_btn.setText("▶️")
|
||
self.play_btn.setEnabled(True)
|
||
|
||
class AlbumResultItem(QFrame):
|
||
"""Expandable UI component for displaying album search results"""
|
||
album_download_requested = pyqtSignal(object) # AlbumResult object
|
||
track_download_requested = pyqtSignal(object) # TrackResult object
|
||
track_stream_requested = pyqtSignal(object, object) # TrackResult object, TrackItem object
|
||
|
||
def __init__(self, album_result, parent=None):
|
||
super().__init__(parent)
|
||
self.album_result = album_result
|
||
self.is_expanded = False
|
||
self.track_items = []
|
||
self.tracks_container = None
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
# Dynamic height based on expansion state with better proportions
|
||
self.collapsed_height = 90 # Increased from 80px for better breathing room
|
||
self.setFixedHeight(self.collapsed_height)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Enable mouse tracking for click detection
|
||
self.setMouseTracking(True)
|
||
|
||
self.setStyleSheet("""
|
||
AlbumResultItem {
|
||
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: 16px;
|
||
border: 1px solid rgba(80, 80, 80, 0.4);
|
||
margin: 8px 4px;
|
||
}
|
||
AlbumResultItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(55, 55, 55, 0.95),
|
||
stop:1 rgba(45, 45, 45, 0.98));
|
||
border: 1px solid rgba(29, 185, 84, 0.7);
|
||
}
|
||
""")
|
||
|
||
# Main vertical layout for album header + tracks
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||
main_layout.setSpacing(0)
|
||
|
||
# Album header (always visible, clickable)
|
||
self.header_widget = QWidget()
|
||
self.header_widget.setFixedHeight(90) # Increased to match collapsed_height
|
||
self.header_widget.setStyleSheet("QWidget { background: transparent; }")
|
||
header_layout = QHBoxLayout(self.header_widget)
|
||
header_layout.setContentsMargins(16, 12, 16, 16) # More balanced padding - reduced top, added bottom
|
||
header_layout.setSpacing(16) # Consistent spacing with other elements
|
||
|
||
# Album icon with expand indicator
|
||
icon_container = QVBoxLayout()
|
||
album_icon = QLabel("💿")
|
||
album_icon.setFixedSize(32, 32)
|
||
album_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
album_icon.setStyleSheet("""
|
||
QLabel {
|
||
font-size: 20px;
|
||
background: rgba(29, 185, 84, 0.1);
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
}
|
||
""")
|
||
|
||
# Expand indicator
|
||
self.expand_indicator = QLabel("▶")
|
||
self.expand_indicator.setFixedSize(16, 16)
|
||
self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(29, 185, 84, 0.8);
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
|
||
icon_container.addWidget(album_icon)
|
||
icon_container.addWidget(self.expand_indicator)
|
||
|
||
# Album info section
|
||
info_section = QVBoxLayout()
|
||
info_section.setSpacing(2)
|
||
info_section.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Album title
|
||
album_title = QLabel(self.album_result.album_title)
|
||
album_title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||
album_title.setStyleSheet("color: #ffffff;")
|
||
|
||
# Artist and details
|
||
details = []
|
||
if self.album_result.artist:
|
||
details.append(self.album_result.artist)
|
||
details.append(f"{self.album_result.track_count} tracks")
|
||
details.append(f"{self.album_result.size_mb}MB")
|
||
details.append(self.album_result.dominant_quality.upper())
|
||
if self.album_result.year:
|
||
details.append(f"({self.album_result.year})")
|
||
|
||
details_text = " • ".join(details)
|
||
album_details = QLabel(details_text)
|
||
album_details.setFont(QFont("Arial", 10))
|
||
album_details.setStyleSheet("color: rgba(179, 179, 179, 0.9);")
|
||
|
||
# User info
|
||
user_info = QLabel(f"👤 {self.album_result.username}")
|
||
user_info.setFont(QFont("Arial", 9))
|
||
user_info.setStyleSheet("color: rgba(29, 185, 84, 0.8);")
|
||
|
||
info_section.addWidget(album_title)
|
||
info_section.addWidget(album_details)
|
||
info_section.addWidget(user_info)
|
||
|
||
# Download button
|
||
self.download_btn = QPushButton("⬇️ Download Album")
|
||
self.download_btn.setFixedSize(120, 36)
|
||
self.download_btn.clicked.connect(self.request_album_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: 12px;
|
||
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));
|
||
}
|
||
""")
|
||
|
||
# Set minimum width to ensure buttons always visible
|
||
self.setMinimumWidth(350) # Ensure minimum space for content + download button
|
||
|
||
# Assembly header
|
||
header_layout.addLayout(icon_container)
|
||
header_layout.addLayout(info_section, 1) # Flexible content area
|
||
header_layout.addWidget(self.download_btn, 0) # Fixed button area, always visible
|
||
|
||
# Tracks container (hidden by default)
|
||
self.tracks_container = QWidget()
|
||
self.tracks_container.setVisible(False)
|
||
tracks_layout = QVBoxLayout(self.tracks_container)
|
||
tracks_layout.setContentsMargins(16, 8, 16, 16)
|
||
tracks_layout.setSpacing(4)
|
||
|
||
# Create track items
|
||
for track in self.album_result.tracks:
|
||
track_item = TrackItem(track)
|
||
track_item.track_download_requested.connect(self.track_download_requested.emit)
|
||
# Use lambda to pass both track result and track item reference
|
||
track_item.track_stream_requested.connect(
|
||
lambda track_result, item=track_item: self.handle_track_stream_request(track_result, item)
|
||
)
|
||
tracks_layout.addWidget(track_item)
|
||
self.track_items.append(track_item)
|
||
|
||
# Assembly main layout
|
||
main_layout.addWidget(self.header_widget)
|
||
main_layout.addWidget(self.tracks_container)
|
||
|
||
# Make header clickable
|
||
self.header_widget.mousePressEvent = self.toggle_expansion
|
||
|
||
def request_album_download(self):
|
||
"""Request download of the entire album"""
|
||
self.download_btn.setText("⏳")
|
||
self.download_btn.setEnabled(False)
|
||
self.album_download_requested.emit(self.album_result)
|
||
|
||
def toggle_expansion(self, event):
|
||
"""Toggle album expansion to show/hide tracks"""
|
||
self.is_expanded = not self.is_expanded
|
||
|
||
if self.is_expanded:
|
||
# Expand to show tracks
|
||
self.tracks_container.setVisible(True)
|
||
self.expand_indicator.setText("▼")
|
||
# Calculate height: header + (tracks * track_height) + padding
|
||
track_height = 54 # 50px + margin
|
||
total_height = self.collapsed_height + (len(self.track_items) * track_height) + 24
|
||
self.setFixedHeight(total_height)
|
||
else:
|
||
# Collapse to hide tracks
|
||
self.tracks_container.setVisible(False)
|
||
self.expand_indicator.setText("▶")
|
||
self.setFixedHeight(self.collapsed_height)
|
||
|
||
# Force layout update
|
||
self.updateGeometry()
|
||
if self.parent():
|
||
self.parent().updateGeometry()
|
||
|
||
def handle_track_stream_request(self, track_result, track_item):
|
||
"""Handle stream request from a track item, passing the correct button reference"""
|
||
# Emit the stream request with the track item that contains the button
|
||
self.track_stream_requested.emit(track_result, track_item)
|
||
|
||
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: 75px, expanded: 200px for better visual breathing room)
|
||
self.compact_height = 75 # Increased from 60px for less cramped feeling
|
||
self.expanded_height = 200 # Increased from 180px for more comfortable content layout
|
||
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: 16px;
|
||
border: 1px solid rgba(64, 64, 64, 0.4);
|
||
margin: 6px 3px;
|
||
}
|
||
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(16, 12, 16, 12) # More generous padding for better breathing room
|
||
layout.setSpacing(16) # Increased spacing for better visual separation
|
||
|
||
# Left section: Music icon + filename
|
||
left_section = QHBoxLayout()
|
||
left_section.setSpacing(12) # Increased from 8px for better separation
|
||
|
||
# Enhanced music icon with better sizing
|
||
music_icon = QLabel("🎵")
|
||
music_icon.setFixedSize(40, 40) # Increased from 32x32 for better presence
|
||
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.4),
|
||
stop:1 rgba(29, 185, 84, 0.2));
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(29, 185, 84, 0.5);
|
||
font-size: 16px;
|
||
}
|
||
QLabel:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||
stop:0 rgba(29, 185, 84, 0.6),
|
||
stop:1 rgba(29, 185, 84, 0.4));
|
||
}
|
||
""")
|
||
|
||
# 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, 2, 0, 2) # Small vertical margins for better text positioning
|
||
self.content_layout.setSpacing(6) # Increased from 3px for better readability
|
||
|
||
# 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(8) # Increased from 4px for better button separation
|
||
|
||
# Play button for streaming preview
|
||
self.play_btn = QPushButton("▶️")
|
||
self.play_btn.setFixedSize(42, 42) # Increased from 36x36 for better clickability
|
||
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: 21px;
|
||
color: #000000;
|
||
font-size: 16px;
|
||
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(42, 42) # Increased from 36x36 for better clickability
|
||
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: 21px;
|
||
color: #000000;
|
||
font-size: 16px;
|
||
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)
|
||
|
||
# Set minimum width to ensure buttons always visible
|
||
self.setMinimumWidth(300) # Ensure minimum space for content + buttons
|
||
|
||
layout.addLayout(left_section, 1) # Flexible content area
|
||
layout.addLayout(buttons_layout, 0) # Fixed button area, always visible
|
||
|
||
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", 14, QFont.Weight.Bold)) # Increased from 11px to 14px for better readability
|
||
self.title_label.setStyleSheet("color: #ffffff; letter-spacing: 0.2px;")
|
||
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 with enhanced styling
|
||
self.expand_indicator = QLabel("⏵")
|
||
self.expand_indicator.setFixedSize(20, 20) # Increased size for better visibility
|
||
self.expand_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 14px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
}
|
||
QLabel:hover {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
background: rgba(29, 185, 84, 0.15);
|
||
}
|
||
""")
|
||
|
||
# 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, 4, 0, 4) # Small margins for better text positioning
|
||
expanded_layout.setSpacing(4) # Increased from 1px to 4px for better readability
|
||
|
||
# Artist info
|
||
self.artist_info = QLabel(primary_info['artist'])
|
||
self.artist_info.setFont(QFont("Arial", 12, QFont.Weight.Normal)) # Increased from 10px to 12px for better readability
|
||
self.artist_info.setStyleSheet("color: rgba(179, 179, 179, 0.9); letter-spacing: 0.1px;")
|
||
|
||
# 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", 10)) # Increased from 9px to 10px for better readability
|
||
self.file_details.setStyleSheet("color: rgba(136, 136, 136, 0.8); letter-spacing: 0.1px;")
|
||
|
||
# User info and quality score in one compact row
|
||
bottom_row = QHBoxLayout()
|
||
bottom_row.setContentsMargins(0, 2, 0, 2) # Small margins for better spacing
|
||
bottom_row.setSpacing(12) # Increased from 8px for better separation
|
||
|
||
# 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", 10, QFont.Weight.Medium)) # Increased from 9px to 10px
|
||
self.user_info.setStyleSheet("color: rgba(29, 185, 84, 0.8); letter-spacing: 0.1px;")
|
||
|
||
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.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
font-size: 14px;
|
||
background: rgba(29, 185, 84, 0.15);
|
||
border-radius: 10px;
|
||
}
|
||
""")
|
||
self.quality_badge.show()
|
||
self.expanded_content.show()
|
||
else:
|
||
self.expand_indicator.setText("⏵")
|
||
self.expand_indicator.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 14px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
}
|
||
QLabel:hover {
|
||
color: rgba(29, 185, 84, 0.9);
|
||
background: rgba(29, 185, 84, 0.15);
|
||
}
|
||
""")
|
||
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 with enhanced easing"""
|
||
from PyQt6.QtCore import QPropertyAnimation, QEasingCurve
|
||
|
||
# Start height animation with smoother easing
|
||
self.animation = QPropertyAnimation(self, b"minimumHeight")
|
||
self.animation.setDuration(300) # Slightly longer for smoother feel
|
||
self.animation.setEasingCurve(QEasingCurve.Type.OutQuart) # More elegant easing curve
|
||
|
||
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 TrackResult"""
|
||
# Handle case where search_result is a list (shouldn't happen but be defensive)
|
||
if isinstance(self.search_result, list):
|
||
if len(self.search_result) > 0:
|
||
# Take the first item if it's a list
|
||
actual_result = self.search_result[0]
|
||
else:
|
||
# Empty list, return defaults
|
||
return {'title': 'Unknown Title', 'artist': 'Unknown Artist'}
|
||
else:
|
||
actual_result = self.search_result
|
||
|
||
# TrackResult objects have parsed metadata available
|
||
if hasattr(actual_result, 'title') and hasattr(actual_result, 'artist'):
|
||
# Use parsed metadata from TrackResult
|
||
return {
|
||
'title': actual_result.title or 'Unknown Title',
|
||
'artist': actual_result.artist or 'Unknown Artist'
|
||
}
|
||
|
||
# Fallback: parse from filename if metadata not available
|
||
if hasattr(actual_result, 'filename'):
|
||
filename = actual_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'
|
||
}
|
||
else:
|
||
# No filename attribute, return defaults
|
||
return {
|
||
'title': 'Unknown Title',
|
||
'artist': 'Unknown Artist'
|
||
}
|
||
|
||
def _create_compact_quality_badge(self):
|
||
"""Create a compact quality indicator badge"""
|
||
# Handle list case defensively
|
||
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
|
||
|
||
quality = result.quality.upper()
|
||
bitrate = 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"""
|
||
# Handle list case defensively
|
||
result = self.search_result[0] if isinstance(self.search_result, list) else self.search_result
|
||
|
||
speed = result.upload_speed
|
||
slots = 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(85) # More generous height for better spacing
|
||
self.setStyleSheet("""
|
||
DownloadItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 40, 40, 0.95),
|
||
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);
|
||
}
|
||
""")
|
||
|
||
# Main horizontal layout
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(16, 12, 16, 12)
|
||
layout.setSpacing(16)
|
||
|
||
# Left section: Filename + uploader (flexible)
|
||
left_section = QVBoxLayout()
|
||
left_section.setSpacing(4)
|
||
|
||
# Extract filename with extension from file_path
|
||
filename_with_ext = "Unknown File"
|
||
if self.file_path:
|
||
from pathlib import Path
|
||
try:
|
||
filename_with_ext = Path(self.file_path).name
|
||
except:
|
||
filename_with_ext = self.title # fallback
|
||
else:
|
||
filename_with_ext = self.title # fallback
|
||
|
||
# Filename with extension (main info)
|
||
filename_label = QLabel(filename_with_ext)
|
||
filename_label.setFont(QFont("Segoe UI", 13, QFont.Weight.Bold))
|
||
filename_label.setStyleSheet("color: #ffffff;")
|
||
filename_label.setWordWrap(False)
|
||
filename_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Uploader info
|
||
uploader_label = QLabel(f"from {self.artist}")
|
||
uploader_label.setFont(QFont("Segoe UI", 10))
|
||
uploader_label.setStyleSheet("color: #b3b3b3;")
|
||
uploader_label.setWordWrap(False)
|
||
uploader_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
left_section.addWidget(filename_label)
|
||
left_section.addWidget(uploader_label)
|
||
|
||
# Middle section: Progress (fixed width)
|
||
progress_widget = QWidget()
|
||
progress_widget.setFixedWidth(120)
|
||
progress_layout = QVBoxLayout(progress_widget)
|
||
progress_layout.setSpacing(6)
|
||
progress_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Progress bar
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setFixedHeight(8)
|
||
self.progress_bar.setValue(self.progress)
|
||
self.progress_bar.setStyleSheet("""
|
||
QProgressBar {
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #404040;
|
||
}
|
||
QProgressBar::chunk {
|
||
background: #1db954;
|
||
border-radius: 4px;
|
||
}
|
||
""")
|
||
|
||
# Status text
|
||
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())
|
||
if self.status.lower() in ["downloading", "queued"]:
|
||
status_text = f"{clean_status} - {self.progress}%"
|
||
else:
|
||
status_text = clean_status
|
||
|
||
self.status_label = QLabel(status_text)
|
||
self.status_label.setFont(QFont("Segoe UI", 9))
|
||
self.status_label.setStyleSheet("color: #b3b3b3;")
|
||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
progress_layout.addWidget(self.progress_bar)
|
||
progress_layout.addWidget(self.status_label)
|
||
|
||
# Right section: Action button (fixed width)
|
||
self.action_btn = QPushButton()
|
||
self.action_btn.setFixedSize(90, 36)
|
||
|
||
if self.status == "downloading":
|
||
self.action_btn.setText("Cancel")
|
||
self.action_btn.clicked.connect(self.cancel_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.8),
|
||
stop:1 rgba(220, 53, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 33, 49, 0.9),
|
||
stop:1 rgba(200, 33, 49, 1.0));
|
||
}
|
||
""")
|
||
elif self.status == "failed":
|
||
self.action_btn.setText("Retry")
|
||
self.action_btn.clicked.connect(self.retry_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.8),
|
||
stop:1 rgba(255, 193, 7, 1.0));
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(235, 173, 0, 0.9),
|
||
stop:1 rgba(235, 173, 0, 1.0));
|
||
}
|
||
""")
|
||
else:
|
||
self.action_btn.setText("📂 Open")
|
||
self.action_btn.clicked.connect(self.open_download_location)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 167, 69, 0.8),
|
||
stop:1 rgba(40, 167, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 147, 49, 0.9),
|
||
stop:1 rgba(20, 147, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Add everything to main layout
|
||
layout.addLayout(left_section, 1) # Flexible
|
||
layout.addWidget(progress_widget) # Fixed width
|
||
layout.addWidget(self.action_btn) # Fixed width
|
||
|
||
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)
|
||
|
||
# Update action button based on status
|
||
if hasattr(self, 'action_btn') and self.action_btn:
|
||
if self.status == "downloading":
|
||
self.action_btn.setText("Cancel")
|
||
# Disconnect old connections
|
||
self.action_btn.clicked.disconnect()
|
||
self.action_btn.clicked.connect(self.cancel_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.8),
|
||
stop:1 rgba(220, 53, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 33, 49, 0.9),
|
||
stop:1 rgba(200, 33, 49, 1.0));
|
||
}
|
||
""")
|
||
elif self.status == "failed":
|
||
self.action_btn.setText("Retry")
|
||
# Disconnect old connections
|
||
self.action_btn.clicked.disconnect()
|
||
self.action_btn.clicked.connect(self.retry_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.8),
|
||
stop:1 rgba(255, 193, 7, 1.0));
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(235, 173, 0, 0.9),
|
||
stop:1 rgba(235, 173, 0, 1.0));
|
||
}
|
||
""")
|
||
else:
|
||
self.action_btn.setText("📂 Open")
|
||
# Disconnect old connections
|
||
self.action_btn.clicked.disconnect()
|
||
self.action_btn.clicked.connect(self.open_download_location)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 167, 69, 0.8),
|
||
stop:1 rgba(40, 167, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 147, 49, 0.9),
|
||
stop:1 rgba(20, 147, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
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 CompactDownloadItem(QFrame):
|
||
"""Compact download item optimized for queue display"""
|
||
def __init__(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 = "", username: str = "",
|
||
soulseek_client=None, queue_type: str = "active", 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
|
||
self.username = username
|
||
self.soulseek_client = soulseek_client
|
||
self.queue_type = queue_type # "active" or "finished"
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setFixedHeight(45) # Compact height for better space utilization
|
||
self.setStyleSheet("""
|
||
CompactDownloadItem {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.8),
|
||
stop:1 rgba(35, 35, 35, 0.9));
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(64, 64, 64, 0.3);
|
||
margin: 2px 1px;
|
||
}
|
||
CompactDownloadItem:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(55, 55, 55, 0.9),
|
||
stop:1 rgba(45, 45, 45, 0.95));
|
||
border: 1px solid rgba(29, 185, 84, 0.5);
|
||
}
|
||
""")
|
||
|
||
# Main horizontal layout
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(10, 6, 10, 6) # Compact margins
|
||
layout.setSpacing(8) # Tight spacing
|
||
|
||
# Left section: Filename + uploader (flexible)
|
||
left_section = QVBoxLayout()
|
||
left_section.setSpacing(1) # Very tight vertical spacing
|
||
left_section.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Extract just filename with extension from file_path
|
||
filename_with_ext = self.get_display_filename()
|
||
|
||
# Filename with extension (main info)
|
||
self.filename_label = QLabel(filename_with_ext)
|
||
self.filename_label.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold))
|
||
self.filename_label.setStyleSheet("color: #ffffff;")
|
||
self.filename_label.setWordWrap(False)
|
||
self.filename_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
# Uploader info (compact)
|
||
uploader_label = QLabel(f"from {self.artist}")
|
||
uploader_label.setFont(QFont("Segoe UI", 8))
|
||
uploader_label.setStyleSheet("color: #b3b3b3;")
|
||
uploader_label.setWordWrap(False)
|
||
uploader_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
|
||
left_section.addWidget(self.filename_label)
|
||
left_section.addWidget(uploader_label)
|
||
|
||
# Middle section: Progress (fixed width)
|
||
progress_widget = QWidget()
|
||
progress_widget.setFixedWidth(80) # Narrower than full version
|
||
progress_layout = QVBoxLayout(progress_widget)
|
||
progress_layout.setSpacing(2)
|
||
progress_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Progress bar (smaller)
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setFixedHeight(6) # Thinner progress bar
|
||
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 (compact)
|
||
status_text = self.get_status_text()
|
||
self.status_label = QLabel(status_text)
|
||
self.status_label.setFont(QFont("Segoe UI", 8))
|
||
self.status_label.setStyleSheet("color: #b3b3b3;")
|
||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
progress_layout.addWidget(self.progress_bar)
|
||
progress_layout.addWidget(self.status_label)
|
||
|
||
# Right section: Action button(s) (fixed width)
|
||
if self.queue_type == "finished":
|
||
# For finished downloads, create a container with two buttons
|
||
button_container = QWidget()
|
||
button_container.setFixedWidth(150) # Wider to fit two buttons
|
||
button_layout = QHBoxLayout(button_container)
|
||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||
button_layout.setSpacing(4)
|
||
|
||
# Open button
|
||
self.open_btn = QPushButton("📂")
|
||
self.open_btn.setFixedSize(32, 32)
|
||
self.open_btn.setToolTip("Open download folder")
|
||
self.open_btn.clicked.connect(self.open_download_location)
|
||
self.open_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 167, 69, 0.8),
|
||
stop:1 rgba(40, 167, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 147, 49, 0.9),
|
||
stop:1 rgba(20, 147, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
# Remove button
|
||
self.remove_btn = QPushButton("🗑️")
|
||
self.remove_btn.setFixedSize(32, 32)
|
||
self.remove_btn.setToolTip("Remove from downloads")
|
||
self.remove_btn.clicked.connect(self.remove_download)
|
||
self.remove_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.8),
|
||
stop:1 rgba(220, 53, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 33, 49, 0.9),
|
||
stop:1 rgba(200, 33, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
button_layout.addWidget(self.open_btn)
|
||
button_layout.addWidget(self.remove_btn)
|
||
|
||
action_widget = button_container
|
||
else:
|
||
# For active downloads, single action button
|
||
self.action_btn = QPushButton()
|
||
self.action_btn.setFixedSize(70, 32) # Smaller button
|
||
self.setup_action_button()
|
||
action_widget = self.action_btn
|
||
|
||
# Add everything to main layout
|
||
layout.addLayout(left_section, 1) # Flexible
|
||
layout.addWidget(progress_widget) # Fixed width
|
||
layout.addWidget(action_widget) # Fixed width
|
||
|
||
def get_display_filename(self):
|
||
"""Extract just the filename with extension for display"""
|
||
if self.file_path:
|
||
from pathlib import Path
|
||
try:
|
||
return Path(self.file_path).name
|
||
except:
|
||
pass
|
||
# Fallback to title if no file_path or error
|
||
return self.title if self.title else "Unknown File"
|
||
|
||
def get_status_text(self):
|
||
"""Get appropriate status text for display"""
|
||
status_mapping = {
|
||
"completed, succeeded": "Done",
|
||
"completed, cancelled": "Cancelled",
|
||
"completed": "Done",
|
||
"cancelled": "Cancelled",
|
||
"downloading": f"{self.progress}%",
|
||
"failed": "Failed",
|
||
"queued": "Queued"
|
||
}
|
||
return status_mapping.get(self.status.lower(), self.status.title())
|
||
|
||
def setup_action_button(self):
|
||
"""Setup the action button based on queue type and status"""
|
||
# Clear any existing connections
|
||
try:
|
||
self.action_btn.clicked.disconnect()
|
||
except:
|
||
pass
|
||
|
||
if self.queue_type == "active":
|
||
# Active downloads: Cancel or Retry buttons
|
||
if self.status.lower() in ["downloading", "queued"]:
|
||
self.action_btn.setText("Cancel")
|
||
self.action_btn.clicked.connect(self.cancel_download)
|
||
print(f"[DEBUG] Connected cancel button for: {self.title}")
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(220, 53, 69, 0.8),
|
||
stop:1 rgba(220, 53, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: bold;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(200, 33, 49, 0.9),
|
||
stop:1 rgba(200, 33, 49, 1.0));
|
||
}
|
||
""")
|
||
elif self.status.lower() == "failed":
|
||
self.action_btn.setText("Retry")
|
||
self.action_btn.clicked.connect(self.retry_download)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(255, 193, 7, 0.8),
|
||
stop:1 rgba(255, 193, 7, 1.0));
|
||
color: #000;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: bold;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(235, 173, 0, 0.9),
|
||
stop:1 rgba(235, 173, 0, 1.0));
|
||
}
|
||
""")
|
||
else:
|
||
# Completed but still in active queue - this shouldn't happen normally
|
||
self.action_btn.setText("Done")
|
||
self.action_btn.setEnabled(False)
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: rgba(100, 100, 100, 0.3);
|
||
color: rgba(255, 255, 255, 0.5);
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 10px;
|
||
}
|
||
""")
|
||
else:
|
||
# Finished downloads: Always show Open button
|
||
self.action_btn.setText("📂 Open")
|
||
self.action_btn.clicked.connect(self.open_download_location)
|
||
print(f"[DEBUG] Connected open button for: {self.title} (path: {self.file_path})")
|
||
self.action_btn.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(40, 167, 69, 0.8),
|
||
stop:1 rgba(40, 167, 69, 1.0));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: bold;
|
||
font-size: 10px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(20, 147, 49, 0.9),
|
||
stop:1 rgba(20, 147, 49, 1.0));
|
||
}
|
||
""")
|
||
|
||
def update_status(self, status: str, progress: int = None, download_speed: int = None, file_path: str = None):
|
||
"""Update the status and progress of the download item"""
|
||
self.status = status
|
||
if progress is not None:
|
||
self.progress = progress
|
||
self.progress_bar.setValue(progress)
|
||
if download_speed is not None:
|
||
self.download_speed = download_speed
|
||
if file_path:
|
||
self.file_path = file_path
|
||
# Update filename display if file_path changed
|
||
if hasattr(self, 'filename_label') and self.filename_label:
|
||
self.filename_label.setText(self.get_display_filename())
|
||
|
||
# Update status text
|
||
self.status_label.setText(self.get_status_text())
|
||
|
||
# Update action button if needed
|
||
self.setup_action_button()
|
||
|
||
def remove_download(self):
|
||
"""Remove the download completely from transfer list"""
|
||
print(f"[DEBUG] Remove button clicked - download_id: {self.download_id}, username: {self.username}, title: {self.title}")
|
||
if self.soulseek_client and self.download_id:
|
||
print(f"🗑️ Removing download: {self.download_id}")
|
||
try:
|
||
import asyncio
|
||
|
||
# Get or create event loop for async operation
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Run the removal with remove=true
|
||
result = loop.run_until_complete(self.soulseek_client.cancel_download(self.download_id, self.username, remove=True))
|
||
print(f"[DEBUG] Remove result: {result}")
|
||
|
||
if result:
|
||
print(f"✅ Successfully removed download: {self.title}")
|
||
print(f"[DEBUG] Download should be removed from slskd - waiting for next status update to reflect removal")
|
||
# Force an immediate status update to check if it's been removed
|
||
print(f"[DEBUG] Triggering immediate status poll to detect removal")
|
||
# Find the downloads page and trigger status update
|
||
downloads_page = self
|
||
while downloads_page and not hasattr(downloads_page, 'update_download_status'):
|
||
downloads_page = downloads_page.parent()
|
||
if downloads_page:
|
||
downloads_page.update_download_status()
|
||
else:
|
||
print(f"❌ Failed to remove download: {self.title}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to remove download: {e}")
|
||
else:
|
||
print(f"[DEBUG] Remove failed - soulseek_client: {self.soulseek_client}, download_id: {self.download_id}")
|
||
|
||
def cancel_download(self):
|
||
"""Cancel the download using soulseek client"""
|
||
print(f"[DEBUG] Cancel button clicked - download_id: {self.download_id}, username: {self.username}, title: {self.title}")
|
||
if self.soulseek_client and self.download_id:
|
||
print(f"🚫 Cancelling download: {self.download_id}")
|
||
try:
|
||
import asyncio
|
||
|
||
# Get or create event loop for async operation
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
# Run the cancellation with username
|
||
result = loop.run_until_complete(self.soulseek_client.cancel_download(self.download_id, self.username))
|
||
print(f"[DEBUG] Cancel result: {result}")
|
||
|
||
if result:
|
||
print(f"✅ Successfully cancelled download: {self.title}")
|
||
self.update_status("cancelled")
|
||
else:
|
||
print(f"❌ Failed to cancel download: {self.title}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to cancel download: {e}")
|
||
else:
|
||
print(f"[DEBUG] Cancel failed - soulseek_client: {self.soulseek_client}, download_id: {self.download_id}")
|
||
|
||
def retry_download(self):
|
||
"""Retry a failed download"""
|
||
print(f"🔄 Retrying download: {self.title}")
|
||
# This would trigger a new download attempt
|
||
# Implementation depends on how retries are handled in the main system
|
||
self.update_status("queued", 0)
|
||
|
||
def open_download_location(self):
|
||
"""Open the download location in file explorer"""
|
||
import os
|
||
import platform
|
||
from pathlib import Path
|
||
|
||
print(f"[DEBUG] Open button clicked - file_path: {self.file_path}, title: {self.title}")
|
||
|
||
if not self.file_path:
|
||
print(f"[DEBUG] No file_path set for download: {self.title}")
|
||
# Fallback to opening the general downloads folder
|
||
try:
|
||
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}"')
|
||
|
||
print(f"📂 Opened downloads folder: {download_path}")
|
||
except Exception as e:
|
||
print(f"❌ Failed to open downloads folder: {e}")
|
||
return
|
||
|
||
try:
|
||
file_path = Path(self.file_path)
|
||
print(f"[DEBUG] Checking file existence: {file_path}")
|
||
|
||
if file_path.exists():
|
||
folder_path = file_path.parent
|
||
print(f"[DEBUG] Opening folder: {folder_path}")
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(folder_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f"open '{folder_path}'")
|
||
else: # Linux
|
||
os.system(f"xdg-open '{folder_path}'")
|
||
|
||
print(f"📂 Opened folder: {folder_path}")
|
||
else:
|
||
print(f"❌ File not found: {file_path}")
|
||
# Try to find the file in the downloads directory using the filename
|
||
filename = os.path.basename(self.file_path)
|
||
print(f"[DEBUG] Searching for file: {filename}")
|
||
|
||
from config.settings import config_manager
|
||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||
|
||
# Search for the file in the downloads directory tree
|
||
found_file = None
|
||
for root, dirs, files in os.walk(download_path):
|
||
for file in files:
|
||
if file == filename:
|
||
found_file = os.path.join(root, file)
|
||
print(f"[DEBUG] Found file at: {found_file}")
|
||
break
|
||
if found_file:
|
||
break
|
||
|
||
if found_file:
|
||
folder_path = os.path.dirname(found_file)
|
||
print(f"[DEBUG] Opening found folder: {folder_path}")
|
||
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
os.startfile(folder_path)
|
||
elif system == "Darwin": # macOS
|
||
os.system(f'open "{folder_path}"')
|
||
else: # Linux
|
||
os.system(f'xdg-open "{folder_path}"')
|
||
|
||
print(f"📂 Opened folder: {folder_path}")
|
||
else:
|
||
print(f"❌ Could not find file {filename} in downloads directory")
|
||
# Fallback to opening the downloads folder
|
||
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}"')
|
||
|
||
print(f"📂 Opened downloads folder as fallback: {download_path}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to open download location: {e}")
|
||
|
||
class DownloadQueue(QFrame):
|
||
def __init__(self, title="Download Queue", queue_type="active", parent=None):
|
||
super().__init__(parent)
|
||
self.queue_title = title
|
||
self.queue_type = queue_type # "active" or "finished"
|
||
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(4) # Reduced from 8 to fit more compact items
|
||
|
||
# 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.")
|
||
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 = "", username: 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 compact download item with queue type
|
||
item = CompactDownloadItem(title, artist, status, progress, file_size, download_speed, file_path, download_id, username, soulseek_client, self.queue_type)
|
||
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:
|
||
# Check for various completed status formats
|
||
if (item.status.lower() in ["completed", "finished"] or
|
||
item.status.lower().startswith("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 and queue types
|
||
self.active_queue = DownloadQueue("Active Downloads", "active")
|
||
self.finished_queue = DownloadQueue("Finished Downloads", "finished")
|
||
|
||
# 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 = "", username: 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, username, 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)
|
||
|
||
# Ensure completed downloads have 100% progress
|
||
final_progress = download_item.progress
|
||
if download_item.status == 'completed':
|
||
final_progress = 100
|
||
print(f"[DEBUG] Ensuring completed download '{download_item.title}' has 100% progress")
|
||
|
||
# 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=final_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,
|
||
username=download_item.username,
|
||
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 both slskd backend and local queues"""
|
||
# Delegate to parent (DownloadsPage) which has access to soulseek_client
|
||
if hasattr(self.parent(), 'clear_completed_downloads'):
|
||
self.parent().clear_completed_downloads()
|
||
else:
|
||
# Fallback to local clearing if parent method not available
|
||
print("[DEBUG] No parent clear method found, clearing locally only")
|
||
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):
|
||
# Signals for media player communication
|
||
track_started = pyqtSignal(object) # Track result object
|
||
track_paused = pyqtSignal()
|
||
track_resumed = pyqtSignal()
|
||
track_stopped = pyqtSignal()
|
||
track_finished = pyqtSignal()
|
||
track_position_updated = pyqtSignal(float, float) # current_position, duration in seconds
|
||
|
||
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.current_filtered_results = [] # Cache for filtered results based on active filter
|
||
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(20, 16, 20, 20) # Increased for better breathing room
|
||
main_layout.setSpacing(16) # Increased spacing for better visual hierarchy
|
||
|
||
# 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(20, 16, 20, 16) # Increased padding for better header prominence
|
||
layout.setSpacing(16) # Increased spacing for better hierarchy
|
||
|
||
# Icon and Title
|
||
title_section = QVBoxLayout()
|
||
title_section.setSpacing(6) # Increased for better title hierarchy
|
||
|
||
title_label = QLabel("🎵 Music Downloads")
|
||
title_label.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold)) # Larger for better prominence
|
||
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)
|
||
|
||
# Filter Controls (initially hidden until we have results)
|
||
self.filter_container = self.create_filter_controls()
|
||
self.filter_container.setVisible(False) # Hide until we have search results
|
||
layout.addWidget(self.filter_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(16, 12, 16, 16) # Improved responsive spacing for better breathing room
|
||
results_layout.setSpacing(12) # Increased spacing for better visual hierarchy
|
||
|
||
# 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(16) # Increased for better card separation and visual breathing room
|
||
self.search_results_layout.setContentsMargins(12, 12, 12, 12) # Increased for better edge spacing
|
||
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(20, 16, 20, 16) # Increased responsive spacing for better visual balance
|
||
layout.setSpacing(16) # Increased spacing for better visual hierarchy
|
||
|
||
# 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_filter_controls(self):
|
||
"""Create elegant filter controls for Albums vs Singles"""
|
||
container = QFrame()
|
||
container.setFixedHeight(55)
|
||
container.setStyleSheet("""
|
||
QFrame {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(45, 45, 45, 0.6),
|
||
stop:1 rgba(35, 35, 35, 0.8));
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(80, 80, 80, 0.25);
|
||
}
|
||
""")
|
||
|
||
layout = QHBoxLayout(container)
|
||
layout.setContentsMargins(16, 8, 16, 8)
|
||
layout.setSpacing(8)
|
||
|
||
# Filter label
|
||
filter_label = QLabel("Filter:")
|
||
filter_label.setStyleSheet("""
|
||
QLabel {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
""")
|
||
|
||
# Initialize filter state
|
||
self.current_filter = "all" # "all", "albums", "singles"
|
||
|
||
# Filter buttons with toggle behavior
|
||
self.filter_all_btn = QPushButton("All")
|
||
self.filter_albums_btn = QPushButton("Albums")
|
||
self.filter_singles_btn = QPushButton("Singles")
|
||
|
||
# Store buttons for easy access
|
||
self.filter_buttons = {
|
||
"all": self.filter_all_btn,
|
||
"albums": self.filter_albums_btn,
|
||
"singles": self.filter_singles_btn
|
||
}
|
||
|
||
# Connect button signals
|
||
self.filter_all_btn.clicked.connect(lambda: self.set_filter("all"))
|
||
self.filter_albums_btn.clicked.connect(lambda: self.set_filter("albums"))
|
||
self.filter_singles_btn.clicked.connect(lambda: self.set_filter("singles"))
|
||
|
||
# Apply styling to all buttons
|
||
for btn_key, btn in self.filter_buttons.items():
|
||
btn.setFixedHeight(32)
|
||
btn.setMinimumWidth(65)
|
||
self.update_filter_button_style(btn, btn_key == "all") # "All" starts active
|
||
|
||
layout.addWidget(filter_label)
|
||
layout.addWidget(self.filter_all_btn)
|
||
layout.addWidget(self.filter_albums_btn)
|
||
layout.addWidget(self.filter_singles_btn)
|
||
layout.addStretch()
|
||
|
||
return container
|
||
|
||
def update_filter_button_style(self, button, is_active):
|
||
"""Update the visual style of filter buttons based on active state"""
|
||
if is_active:
|
||
button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1ed760,
|
||
stop:1 #1db954);
|
||
border: none;
|
||
border-radius: 16px;
|
||
color: #000000;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1fdf64,
|
||
stop:1 #1ed760);
|
||
transform: scale(1.02);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1ca851,
|
||
stop:1 #169c46);
|
||
transform: scale(0.98);
|
||
}
|
||
""")
|
||
else:
|
||
button.setStyleSheet("""
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(80, 80, 80, 0.4),
|
||
stop:1 rgba(60, 60, 60, 0.6));
|
||
border: 1px solid rgba(120, 120, 120, 0.3);
|
||
border-radius: 16px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
padding: 0 12px;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(100, 100, 100, 0.5),
|
||
stop:1 rgba(80, 80, 80, 0.7));
|
||
border: 1px solid rgba(140, 140, 140, 0.4);
|
||
color: rgba(255, 255, 255, 0.9);
|
||
transform: scale(1.02);
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 rgba(60, 60, 60, 0.6),
|
||
stop:1 rgba(40, 40, 40, 0.8));
|
||
transform: scale(0.98);
|
||
}
|
||
""")
|
||
|
||
def set_filter(self, filter_type):
|
||
"""Set the active filter and update UI"""
|
||
self.current_filter = filter_type
|
||
|
||
# Update button styles
|
||
for btn_key, btn in self.filter_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == filter_type)
|
||
|
||
# Apply the filter to current results
|
||
self.apply_filter()
|
||
|
||
def apply_filter(self):
|
||
"""Apply the current filter to search results"""
|
||
if not hasattr(self, '_temp_tracks') or not hasattr(self, '_temp_albums'):
|
||
return
|
||
|
||
# Get the filtered results based on current filter
|
||
if self.current_filter == "all":
|
||
filtered_results = self._temp_albums + self._temp_tracks
|
||
elif self.current_filter == "albums":
|
||
filtered_results = self._temp_albums
|
||
elif self.current_filter == "singles":
|
||
filtered_results = self._temp_tracks
|
||
else:
|
||
filtered_results = self._temp_albums + self._temp_tracks
|
||
|
||
# Update the filtered results cache for pagination
|
||
self.current_filtered_results = filtered_results
|
||
|
||
# Clear current display
|
||
self.clear_search_results()
|
||
self.displayed_results = 0
|
||
|
||
# Show filtered results (respecting pagination)
|
||
remaining_slots = self.results_per_page
|
||
results_to_show = filtered_results[:remaining_slots]
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
for result in results_to_show:
|
||
if isinstance(result, AlbumResult):
|
||
# Create expandable album result item
|
||
result_item = AlbumResultItem(result)
|
||
result_item.album_download_requested.connect(self.start_album_download)
|
||
result_item.track_download_requested.connect(self.start_download)
|
||
result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item))
|
||
else:
|
||
# Create individual track result item
|
||
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)
|
||
|
||
self.displayed_results = len(results_to_show)
|
||
|
||
# Re-enable layout updates
|
||
self.search_results_widget.setUpdatesEnabled(True)
|
||
|
||
# Update status to show filter results
|
||
total_albums = len(self._temp_albums)
|
||
total_tracks = len(self._temp_tracks)
|
||
total_filtered = len(filtered_results)
|
||
|
||
if self.current_filter == "all":
|
||
filter_status = f"Showing all {total_filtered} results"
|
||
elif self.current_filter == "albums":
|
||
filter_status = f"Showing {total_albums} albums"
|
||
elif self.current_filter == "singles":
|
||
filter_status = f"Showing {total_tracks} singles"
|
||
else:
|
||
filter_status = f"Showing {total_filtered} results"
|
||
|
||
# Update the search status to reflect filtering
|
||
if total_filtered > 0:
|
||
if total_filtered > self.results_per_page:
|
||
filter_status += f" (showing first {len(results_to_show)})"
|
||
self.search_status.setText(f"✨ {filter_status} • {total_albums} albums, {total_tracks} singles")
|
||
else:
|
||
self.search_status.setText(f"No results found for '{self.current_filter}' filter")
|
||
|
||
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(16, 14, 16, 16) # Increased for better visual breathing room
|
||
layout.setSpacing(14) # Increased spacing for better section separation
|
||
|
||
# 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)
|
||
|
||
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(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
|
||
|
||
# Reset filter to "all" and hide filter controls
|
||
self.current_filter = "all"
|
||
if hasattr(self, 'filter_buttons'):
|
||
for btn_key, btn in self.filter_buttons.items():
|
||
self.update_filter_button_style(btn, btn_key == "all")
|
||
self.filter_container.setVisible(False)
|
||
|
||
# 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, tracks, albums, response_count):
|
||
"""Handle progressive search results as they come in"""
|
||
# Combine tracks and albums into a single list for display (albums first, then tracks)
|
||
combined_results = albums + tracks
|
||
|
||
# Initialize temp results if not exists
|
||
if not hasattr(self, '_temp_search_results'):
|
||
self._temp_search_results = []
|
||
if not hasattr(self, '_temp_tracks'):
|
||
self._temp_tracks = []
|
||
if not hasattr(self, '_temp_albums'):
|
||
self._temp_albums = []
|
||
|
||
# Store tracks and albums separately and combined
|
||
self._temp_tracks = tracks.copy() # Replace with full updated list
|
||
self._temp_albums = albums.copy() # Replace with full updated list
|
||
self._temp_search_results = combined_results.copy()
|
||
|
||
# Update filtered results cache to match current filter
|
||
if hasattr(self, 'current_filter'):
|
||
if self.current_filter == "all":
|
||
self.current_filtered_results = combined_results.copy()
|
||
elif self.current_filter == "albums":
|
||
self.current_filtered_results = albums.copy()
|
||
elif self.current_filter == "singles":
|
||
self.current_filtered_results = tracks.copy()
|
||
else:
|
||
self.current_filtered_results = combined_results.copy()
|
||
else:
|
||
self.current_filtered_results = combined_results.copy()
|
||
|
||
# Clear existing results and display the updated complete set
|
||
# This ensures proper sorting and no duplicates
|
||
self.clear_search_results()
|
||
self.displayed_results = 0
|
||
|
||
# Only display up to the current page limit
|
||
remaining_slots = self.results_per_page
|
||
results_to_show = combined_results[:remaining_slots]
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
for result in results_to_show:
|
||
if isinstance(result, AlbumResult):
|
||
# Create expandable album result item
|
||
result_item = AlbumResultItem(result)
|
||
result_item.album_download_requested.connect(self.start_album_download)
|
||
result_item.track_download_requested.connect(self.start_download) # Individual track downloads
|
||
result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item)) # Individual track streaming
|
||
else:
|
||
# Create individual track result item
|
||
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)
|
||
|
||
# Show filter controls during live search when we have meaningful results
|
||
total_results = len(tracks) + len(albums)
|
||
should_show_filters = (
|
||
# Show if we have both albums and tracks (diverse results)
|
||
(len(albums) > 0 and len(tracks) > 0) or
|
||
# Or if we have enough results to make filtering useful
|
||
total_results >= 5
|
||
)
|
||
|
||
if should_show_filters and not self.filter_container.isVisible():
|
||
self.filter_container.setVisible(True)
|
||
|
||
# Update status message with real-time feedback
|
||
if self.displayed_results < self.results_per_page:
|
||
self.update_search_status(f"✨ Found {total_results} results ({len(tracks)} tracks, {len(albums)} albums) from {response_count} users • Live updating...", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✨ Found {total_results} results ({len(tracks)} tracks, {len(albums)} albums) from {response_count} users • 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 the temp results that have been accumulating during live updates
|
||
if hasattr(self, '_temp_tracks') and hasattr(self, '_temp_albums'):
|
||
tracks = self._temp_tracks
|
||
albums = self._temp_albums
|
||
combined_results = self._temp_search_results
|
||
elif isinstance(results, tuple) and len(results) == 2:
|
||
# Fallback to final results if temp not available
|
||
tracks, albums = results
|
||
combined_results = albums + tracks
|
||
else:
|
||
# Fallback for old list format or empty results
|
||
tracks = results or []
|
||
albums = []
|
||
combined_results = results or []
|
||
|
||
# Store final results
|
||
self.search_results = combined_results
|
||
self.current_filtered_results = combined_results # Initialize with all results
|
||
self.track_results = tracks
|
||
self.album_results = albums
|
||
|
||
total_results = len(combined_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")
|
||
# Hide filter controls when no results
|
||
self.filter_container.setVisible(False)
|
||
return
|
||
|
||
# Update status with album/track breakdown
|
||
album_count = len(albums)
|
||
track_count = len(tracks)
|
||
|
||
status_parts = []
|
||
if album_count > 0:
|
||
status_parts.append(f"{album_count} album{'s' if album_count != 1 else ''}")
|
||
if track_count > 0:
|
||
status_parts.append(f"{track_count} track{'s' if track_count != 1 else ''}")
|
||
|
||
result_summary = " • ".join(status_parts) if status_parts else f"{total_results} results"
|
||
|
||
# Show filter controls when we have results
|
||
self.filter_container.setVisible(True)
|
||
|
||
# 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"✅ Search completed • Found {result_summary} • Showing first {self.displayed_results} (scroll down for {remaining} more)", "#1db954")
|
||
else:
|
||
self.update_search_status(f"✅ Search completed • Found {result_summary}", "#1db954")
|
||
|
||
def clear_search_results(self):
|
||
"""Clear all search result items from the layout"""
|
||
# Remove all SearchResultItem and AlbumResultItem 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():
|
||
widget = item.widget()
|
||
if isinstance(widget, (SearchResultItem, AlbumResultItem)):
|
||
items_to_remove.append(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.current_filtered_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.current_filtered_results):
|
||
self.load_more_results()
|
||
|
||
def load_more_results(self):
|
||
"""Load the next batch of search results (respecting current filter)"""
|
||
if self.is_loading_more or not self.current_filtered_results:
|
||
return
|
||
|
||
self.is_loading_more = True
|
||
|
||
# Calculate how many more results to show from filtered results
|
||
start_index = self.displayed_results
|
||
end_index = min(start_index + self.results_per_page, len(self.current_filtered_results))
|
||
|
||
# Temporarily disable layout updates for smoother batch loading
|
||
self.search_results_widget.setUpdatesEnabled(False)
|
||
|
||
# Add result items to UI from filtered results
|
||
for i in range(start_index, end_index):
|
||
result = self.current_filtered_results[i]
|
||
|
||
# Create appropriate UI component based on result type
|
||
if isinstance(result, AlbumResult):
|
||
# Create expandable album result item
|
||
result_item = AlbumResultItem(result)
|
||
result_item.album_download_requested.connect(self.start_album_download)
|
||
result_item.track_download_requested.connect(self.start_download) # Individual track downloads
|
||
result_item.track_stream_requested.connect(lambda search_result, track_item: self.start_stream(search_result, track_item)) # Individual track streaming
|
||
else:
|
||
# Create track result item (play + download)
|
||
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 based on filtered results
|
||
total_filtered = len(self.current_filtered_results)
|
||
if self.displayed_results >= total_filtered:
|
||
# Determine filter status text
|
||
if self.current_filter == "albums":
|
||
filter_text = "albums"
|
||
elif self.current_filter == "singles":
|
||
filter_text = "singles"
|
||
else:
|
||
filter_text = "results"
|
||
self.update_search_status(f"✨ Showing all {total_filtered} {filter_text}", "#1db954")
|
||
else:
|
||
remaining = total_filtered - self.displayed_results
|
||
# Determine filter status text
|
||
if self.current_filter == "albums":
|
||
filter_text = "albums"
|
||
elif self.current_filter == "singles":
|
||
filter_text = "singles"
|
||
else:
|
||
filter_text = "results"
|
||
self.update_search_status(f"✨ Showing {self.displayed_results} of {total_filtered} {filter_text} (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
|
||
full_filename = search_result.filename
|
||
|
||
# Extract just the filename part (without directory path)
|
||
import os
|
||
filename = os.path.basename(full_filename)
|
||
|
||
# Parse the filename to extract artist and title
|
||
# First, remove file extension
|
||
name_without_ext = filename
|
||
if '.' in name_without_ext:
|
||
name_without_ext = '.'.join(name_without_ext.split('.')[:-1])
|
||
|
||
# Check for track number prefix (e.g., "01. ", "28. ", etc.)
|
||
import re
|
||
track_number_match = re.match(r'^(\d+)\.\s*(.+)', name_without_ext)
|
||
if track_number_match:
|
||
track_number = track_number_match.group(1)
|
||
name_without_track_num = track_number_match.group(2)
|
||
print(f"[DEBUG] Detected album track: #{track_number} - '{name_without_track_num}'")
|
||
else:
|
||
name_without_track_num = name_without_ext
|
||
|
||
# Now parse artist and title from the cleaned filename
|
||
parts = name_without_track_num.split(' - ')
|
||
if len(parts) >= 2:
|
||
artist = parts[0].strip()
|
||
title = ' - '.join(parts[1:]).strip()
|
||
else:
|
||
# If no ' - ' separator, use the cleaned filename as title
|
||
title = name_without_track_num.strip()
|
||
artist = search_result.username
|
||
|
||
# Final cleanup - ensure we have meaningful values
|
||
if not title or title == '':
|
||
title = name_without_ext # Fallback to full filename without extension
|
||
if not artist or artist == '':
|
||
artist = search_result.username # Fallback to uploader
|
||
|
||
print(f"[DEBUG] Extracted title info from '{full_filename}' -> title: '{title}', artist: '{artist}'")
|
||
|
||
# Generate a unique download ID for tracking and cancellation
|
||
import time
|
||
download_id = f"{search_result.username}_{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,
|
||
username=search_result.username,
|
||
file_path=full_filename, # Store the full path for matching
|
||
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_album_download(self, album_result):
|
||
"""Start downloading all tracks in an album"""
|
||
try:
|
||
print(f"🎵 Starting album download: {album_result.album_title} by {album_result.artist}")
|
||
|
||
# First, find and disable all track download buttons for this album
|
||
self.disable_album_track_buttons(album_result)
|
||
|
||
# Download each track in the album
|
||
for track in album_result.tracks:
|
||
self.start_download(track)
|
||
|
||
print(f"✓ Queued {len(album_result.tracks)} tracks for download from album: {album_result.album_title}")
|
||
|
||
except Exception as e:
|
||
print(f"Failed to start album download: {str(e)}")
|
||
|
||
def disable_album_track_buttons(self, album_result):
|
||
"""Disable all track download buttons for an album to prevent duplicate downloads"""
|
||
# Find the AlbumResultItem that contains these tracks
|
||
for album_item in self.findChildren(AlbumResultItem):
|
||
if (album_item.album_result.album_title == album_result.album_title and
|
||
album_item.album_result.artist == album_result.artist):
|
||
|
||
# Disable all track download buttons in this album
|
||
for track_item in album_item.track_items:
|
||
track_item.set_download_queued_state()
|
||
print(f"[DEBUG] Disabled {len(album_item.track_items)} track download buttons for album: {album_result.album_title}")
|
||
break
|
||
|
||
def update_album_track_button_states(self, download_item, status):
|
||
"""Update track download button states based on download progress"""
|
||
# Find the track item that corresponds to this download
|
||
for album_item in self.findChildren(AlbumResultItem):
|
||
for track_item in album_item.track_items:
|
||
# Match by track title and artist
|
||
if (track_item.track_result.title == download_item.title and
|
||
track_item.track_result.artist == download_item.artist):
|
||
|
||
# Update button state based on download status
|
||
if status == 'downloading':
|
||
track_item.set_download_downloading_state()
|
||
elif status in ['completed', 'finished']:
|
||
track_item.set_download_completed_state()
|
||
elif status in ['queued', 'initializing']:
|
||
track_item.set_download_queued_state()
|
||
elif status in ['failed', 'cancelled']:
|
||
track_item.reset_download_state() # Allow retry
|
||
|
||
print(f"[DEBUG] Updated track button state for '{download_item.title}': {status}")
|
||
return
|
||
|
||
def start_stream(self, search_result, result_item=None):
|
||
"""Start streaming a search result using StreamingThread or toggle if same track"""
|
||
try:
|
||
# Check if this is the same track that's currently playing
|
||
current_track_id = getattr(self, 'current_track_id', None)
|
||
new_track_id = f"{search_result.username}:{search_result.filename}"
|
||
|
||
print(f"🎮 start_stream() called for: {search_result.filename}")
|
||
print(f"🎮 Current track ID: {current_track_id}")
|
||
print(f"🎮 New track ID: {new_track_id}")
|
||
print(f"🎮 Currently playing button: {self.currently_playing_button}")
|
||
print(f"🎮 Result item: {result_item}")
|
||
print(f"🎮 Button match: {self.currently_playing_button == result_item}")
|
||
print(f"🎮 Track ID match: {current_track_id == new_track_id}")
|
||
|
||
if current_track_id == new_track_id and self.currently_playing_button == result_item:
|
||
# Same track clicked - toggle playback
|
||
print(f"🔄 Toggling playback for: {search_result.filename}")
|
||
|
||
toggle_result = self.audio_player.toggle_playback()
|
||
print(f"🔄 toggle_playback() returned: {toggle_result}")
|
||
|
||
if toggle_result:
|
||
# Now playing
|
||
result_item.set_playing_state()
|
||
self.track_resumed.emit()
|
||
print("🎵 Song card: Resumed playback")
|
||
else:
|
||
# Now paused
|
||
result_item.set_loading_state() # Use loading as "paused" state
|
||
self.track_paused.emit()
|
||
print("⏸️ Song card: Paused playback")
|
||
|
||
return
|
||
else:
|
||
print(f"🆕 Different track or button - starting new stream")
|
||
|
||
print(f"Starting stream: {search_result.filename} from {search_result.username}")
|
||
|
||
# Different track - stop current and start new
|
||
if self.currently_playing_button:
|
||
self.audio_player.stop_playback()
|
||
self.currently_playing_button.reset_play_state()
|
||
|
||
# Track the new currently playing button and track
|
||
self.currently_playing_button = result_item
|
||
self.current_track_id = new_track_id
|
||
self.current_track_result = search_result
|
||
|
||
# Clear Stream folder before starting new stream (release current file since we're switching)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# 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 any audio file in the stream folder (should only be one)
|
||
stream_file = None
|
||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||
|
||
for filename in os.listdir(stream_folder):
|
||
file_path = os.path.join(stream_folder, filename)
|
||
if (os.path.isfile(file_path) and
|
||
os.path.splitext(filename)[1].lower() in audio_extensions):
|
||
stream_file = file_path
|
||
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()
|
||
# Emit track started signal for sidebar media player
|
||
if hasattr(self, 'current_track_result') and self.current_track_result:
|
||
self.track_started.emit(self.current_track_result)
|
||
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
|
||
|
||
# Emit track finished signal for sidebar media player
|
||
self.track_finished.emit()
|
||
|
||
# Clear Stream folder when playback finishes (release file since playback is done)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Clear track state
|
||
self.current_track_id = None
|
||
self.current_track_result = 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
|
||
|
||
# Emit track stopped signal for sidebar media player
|
||
self.track_stopped.emit()
|
||
|
||
# Clear Stream folder when playback errors (release file since there's an error)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Clear track state
|
||
self.current_track_id = None
|
||
self.current_track_result = None
|
||
|
||
def handle_sidebar_play_pause(self):
|
||
"""Handle play/pause request from sidebar media player"""
|
||
# Use the actual QMediaPlayer state instead of manual flag
|
||
from PyQt6.QtMultimedia import QMediaPlayer
|
||
|
||
current_state = self.audio_player.playbackState()
|
||
print(f"🎮 handle_sidebar_play_pause() - Current state: {current_state}")
|
||
print(f"🎮 handle_sidebar_play_pause() - Current source: {self.audio_player.source().toString()}")
|
||
|
||
if current_state == QMediaPlayer.PlaybackState.PlayingState:
|
||
print("⏸️ Sidebar: Pausing playback")
|
||
self.audio_player.pause()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.set_loading_state() # Use as "paused" state
|
||
self.track_paused.emit()
|
||
print("⏸️ Paused from sidebar")
|
||
else:
|
||
print("▶️ Sidebar: Attempting to resume/play")
|
||
self.audio_player.play()
|
||
# is_playing will be set automatically by _on_playback_state_changed
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.set_playing_state()
|
||
self.track_resumed.emit()
|
||
print("🎵 Resumed from sidebar")
|
||
|
||
def handle_sidebar_stop(self):
|
||
"""Handle stop request from sidebar media player"""
|
||
self.audio_player.stop_playback()
|
||
if self.currently_playing_button:
|
||
self.currently_playing_button.reset_play_state()
|
||
self.currently_playing_button = None
|
||
|
||
# Emit track stopped signal
|
||
self.track_stopped.emit()
|
||
|
||
# Clear Stream folder when stopping (release file since user explicitly stopped)
|
||
self.clear_stream_folder(release_current_file=True)
|
||
|
||
# Clear track state
|
||
self.current_track_id = None
|
||
self.current_track_result = None
|
||
print("⏹️ Stopped from sidebar")
|
||
|
||
def handle_sidebar_volume(self, volume):
|
||
"""Handle volume change from sidebar media player"""
|
||
self.audio_player.audio_output.setVolume(volume)
|
||
print(f"🔊 Volume set to {int(volume * 100)}% from sidebar")
|
||
|
||
def clear_stream_folder(self, release_current_file=True):
|
||
"""Clear all files from the Stream folder to prevent playing wrong files
|
||
|
||
Args:
|
||
release_current_file (bool): Whether to release the current audio file handle.
|
||
Set to False if you want to clear old files but keep current playback.
|
||
"""
|
||
try:
|
||
# Only release file handles if explicitly requested
|
||
if release_current_file and hasattr(self, 'audio_player') and self.audio_player:
|
||
self.audio_player.release_file()
|
||
print("🔓 Released audio player file handle before clearing")
|
||
|
||
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')
|
||
|
||
if os.path.exists(stream_folder):
|
||
for filename in os.listdir(stream_folder):
|
||
file_path = os.path.join(stream_folder, filename)
|
||
if os.path.isfile(file_path):
|
||
try:
|
||
os.remove(file_path)
|
||
print(f"🗑️ Cleared old stream file: {filename}")
|
||
except Exception as e:
|
||
print(f"⚠️ Could not remove stream file {filename}: {e}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error clearing stream folder: {e}")
|
||
|
||
def on_download_completed(self, message, download_item):
|
||
"""Handle successful download start (NOT completion)"""
|
||
print(f"Download started: {message}")
|
||
|
||
# Extract download ID from message if available
|
||
if "Download started:" in message and download_item:
|
||
# Message format is "Download started: <download_id>"
|
||
download_id_part = message.replace("Download started:", "").strip()
|
||
|
||
# Check if this looks like a UUID (real download ID) vs filename
|
||
import re
|
||
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||
if re.match(uuid_pattern, download_id_part, re.IGNORECASE):
|
||
download_item.download_id = download_id_part
|
||
print(f"[DEBUG] Stored real download ID: {download_id_part}")
|
||
else:
|
||
print(f"[DEBUG] Using filename as download ID: {download_id_part}")
|
||
download_item.download_id = download_id_part
|
||
|
||
# Set status to downloading, not completed!
|
||
download_item.status = "downloading"
|
||
download_item.progress = 0
|
||
|
||
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 both slskd backend and local queue"""
|
||
if not self.soulseek_client:
|
||
print("[ERROR] No soulseek client available for clearing downloads")
|
||
return
|
||
|
||
# Run async clear operation
|
||
import asyncio
|
||
try:
|
||
# Get the current event loop or create a new one
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
print("[DEBUG] 🗑️ Clearing all completed downloads from slskd backend...")
|
||
success = loop.run_until_complete(self.soulseek_client.clear_all_completed_downloads())
|
||
|
||
if success:
|
||
print("[DEBUG] ✅ Successfully cleared completed downloads from backend")
|
||
# Also clear from local UI
|
||
self.download_queue.clear_completed_downloads()
|
||
# Trigger immediate status update to refresh UI
|
||
self.update_download_status()
|
||
else:
|
||
print("[ERROR] ❌ Failed to clear completed downloads from backend")
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Exception during clear completed downloads: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
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(transfers_data):
|
||
"""Handle the transfer status update from /api/v0/transfers/downloads endpoint"""
|
||
try:
|
||
if not transfers_data:
|
||
return
|
||
|
||
# Flatten the transfers data structure
|
||
all_transfers = []
|
||
for user_data in transfers_data:
|
||
if 'directories' in user_data:
|
||
for directory in user_data['directories']:
|
||
if 'files' in directory:
|
||
all_transfers.extend(directory['files'])
|
||
|
||
print(f"[DEBUG] Processing {len(all_transfers)} active transfers from API")
|
||
|
||
# Update download items based on transfer data
|
||
for download_item in self.download_queue.download_items.copy(): # Use copy to avoid modification during iteration
|
||
if download_item.status.lower() in ['completed', 'finished', 'cancelled', 'failed']:
|
||
continue # Skip completed items
|
||
|
||
print(f"[DEBUG] Looking for matches for download: '{download_item.title}' by '{download_item.artist}' (download_id: {getattr(download_item, 'download_id', 'None')})")
|
||
|
||
# Try to match by download_id first (most reliable)
|
||
matching_transfer = None
|
||
|
||
if hasattr(download_item, 'download_id') and download_item.download_id:
|
||
for transfer in all_transfers:
|
||
if transfer.get('id') == download_item.download_id:
|
||
matching_transfer = transfer
|
||
print(f"[DEBUG] ✅ Found ID match: {transfer.get('id')} -> {transfer.get('filename', 'Unknown')}")
|
||
break
|
||
|
||
# If no ID match, try improved filename matching as fallback
|
||
if not matching_transfer:
|
||
print(f"[DEBUG] No ID match found, trying filename matching...")
|
||
for transfer in all_transfers:
|
||
full_filename = transfer.get('filename', '')
|
||
transfer_filename = full_filename.lower()
|
||
|
||
# Extract just the filename part (without directory path) for better matching
|
||
import os
|
||
basename = os.path.basename(full_filename).lower()
|
||
|
||
# Normalize both sides for better matching
|
||
download_title_lower = download_item.title.lower()
|
||
basename_lower = basename.lower()
|
||
|
||
# Try multiple matching strategies for better accuracy
|
||
matches = False
|
||
match_reason = ""
|
||
|
||
# Strategy 1: Direct filename match (most reliable)
|
||
if basename_lower == download_title_lower + '.mp3' or basename_lower == download_title_lower + '.flac':
|
||
matches = True
|
||
match_reason = f"direct filename match '{download_title_lower}' == '{basename_lower}'"
|
||
|
||
# Strategy 2: Match track title in the actual filename
|
||
elif download_title_lower in basename_lower:
|
||
matches = True
|
||
match_reason = f"track title '{download_title_lower}' in filename '{basename_lower}'"
|
||
|
||
# Strategy 3: For album tracks, try to match by removing common prefixes
|
||
elif ' - ' in download_item.title:
|
||
# Extract just the song title part (e.g., "DAMN. - 01 - BLOOD" -> "BLOOD")
|
||
title_parts = download_item.title.split(' - ')
|
||
if len(title_parts) >= 3: # Format: "Album - TrackNum - Title"
|
||
song_title = title_parts[-1].strip().lower()
|
||
if song_title in basename_lower and len(song_title) > 2: # Avoid matching very short titles
|
||
matches = True
|
||
match_reason = f"extracted song title '{song_title}' in filename '{basename_lower}'"
|
||
|
||
# Strategy 3.5: Core track name matching (remove parenthetical content)
|
||
elif '(' in download_item.title and ')' in download_item.title:
|
||
# Extract core track name by removing parenthetical content like "(Original Mix)"
|
||
import re
|
||
core_title = re.sub(r'\([^)]*\)', '', download_item.title).strip()
|
||
core_title_lower = core_title.lower()
|
||
|
||
if core_title_lower and len(core_title_lower) > 2:
|
||
# Check if core title words (excluding common terms) are in filename
|
||
common_music_terms = {'original', 'mix', 'remix', 'extended', 'radio', 'edit', 'version', 'album', 'single', 'feat', 'featuring'}
|
||
core_words = [w.lower() for w in core_title.split() if len(w) >= 4 and w.lower() not in common_music_terms]
|
||
|
||
if core_words:
|
||
matching_core_words = [w for w in core_words if w in basename_lower]
|
||
if len(matching_core_words) >= min(2, len(core_words)): # At least 2 unique words
|
||
matches = True
|
||
match_reason = f"core track name match: {matching_core_words} from '{core_title}' in '{basename_lower}'"
|
||
|
||
# Strategy 4: Improved word matching (exclude common music terms)
|
||
elif any(word.lower() in basename_lower for word in download_item.title.split() if len(word) >= 4):
|
||
# Define common music terms to exclude from matching
|
||
common_music_terms = {'original', 'mix', 'remix', 'extended', 'radio', 'edit', 'version', 'album', 'single', 'feat', 'featuring'}
|
||
|
||
# Get meaningful words (longer, not common music terms)
|
||
title_words = [w.lower() for w in download_item.title.split()
|
||
if len(w) >= 4 and w.lower() not in common_music_terms]
|
||
matching_words = [w for w in title_words if w in basename_lower]
|
||
|
||
# Require at least 3 unique meaningful words for a match (stricter than before)
|
||
if len(matching_words) >= min(3, len(title_words)) and len(matching_words) >= 2:
|
||
matches = True
|
||
match_reason = f"meaningful words match: {matching_words} in '{basename_lower}' (excluded common terms)"
|
||
|
||
# Strategy 5: Match by download_item's stored file_path if available
|
||
elif download_item.file_path:
|
||
stored_filename = os.path.basename(download_item.file_path).lower()
|
||
if stored_filename == basename_lower:
|
||
matches = True
|
||
match_reason = f"exact filename match '{stored_filename}'"
|
||
|
||
if matches:
|
||
matching_transfer = transfer
|
||
print(f"[DEBUG] ✅ Found filename match: {match_reason}")
|
||
break
|
||
else:
|
||
print(f"[DEBUG] ❌ No match: download_title='{download_title_lower}' vs filename='{basename_lower}'")
|
||
|
||
if not matching_transfer:
|
||
print(f"[DEBUG] ⚠️ No matching transfer found for download_title='{download_item.title}' by artist='{download_item.artist}'")
|
||
|
||
if matching_transfer:
|
||
# Extract progress information
|
||
state = matching_transfer.get('state', 'Unknown')
|
||
progress = matching_transfer.get('percentComplete', 0)
|
||
bytes_transferred = matching_transfer.get('bytesTransferred', 0)
|
||
total_size = matching_transfer.get('size', 0)
|
||
avg_speed = matching_transfer.get('averageSpeed', 0)
|
||
remaining_time = matching_transfer.get('remainingTime', '')
|
||
|
||
# Ensure completed downloads show 100% progress
|
||
if 'Completed' in state or 'Succeeded' in state:
|
||
progress = 100
|
||
|
||
print(f"[DEBUG] Found transfer for '{download_item.title}': {state} - {progress:.1f}%")
|
||
|
||
# Map slskd states to our download states (handle compound states)
|
||
if 'InProgress' in state:
|
||
new_status = 'downloading'
|
||
elif 'Completed' in state or 'Succeeded' in state:
|
||
new_status = 'completed'
|
||
# Construct absolute file path for completed downloads
|
||
api_filename = matching_transfer.get('filename', '')
|
||
if api_filename and self.soulseek_client and self.soulseek_client.download_path:
|
||
from pathlib import Path
|
||
# Convert API filename to absolute path using configured download directory
|
||
absolute_file_path = str(Path(self.soulseek_client.download_path) / api_filename)
|
||
print(f"[DEBUG] Constructed absolute path: {absolute_file_path}")
|
||
else:
|
||
absolute_file_path = download_item.file_path
|
||
|
||
# Update the download item status and progress BEFORE moving
|
||
download_item.update_status(
|
||
status=new_status,
|
||
progress=100, # Force 100% for completed downloads
|
||
download_speed=int(avg_speed),
|
||
file_path=absolute_file_path
|
||
)
|
||
# Move completed items to finished queue
|
||
print(f"[DEBUG] Moving completed download '{download_item.title}' to finished queue")
|
||
self.download_queue.move_to_finished(download_item)
|
||
continue
|
||
elif 'Cancelled' in state or 'Canceled' in state:
|
||
new_status = 'cancelled'
|
||
# Update the download item status BEFORE moving
|
||
download_item.update_status(
|
||
status=new_status,
|
||
progress=download_item.progress, # Keep current progress
|
||
download_speed=0, # No speed for cancelled
|
||
file_path=download_item.file_path
|
||
)
|
||
print(f"[DEBUG] Moving cancelled download '{download_item.title}' to finished queue")
|
||
self.download_queue.move_to_finished(download_item)
|
||
continue
|
||
elif 'Failed' in state or 'Errored' in state:
|
||
new_status = 'failed'
|
||
# Update the download item status BEFORE moving
|
||
download_item.update_status(
|
||
status=new_status,
|
||
progress=download_item.progress, # Keep current progress
|
||
download_speed=0, # No speed for failed
|
||
file_path=download_item.file_path
|
||
)
|
||
print(f"[DEBUG] Moving failed download '{download_item.title}' to finished queue")
|
||
self.download_queue.move_to_finished(download_item)
|
||
continue
|
||
elif 'Queued' in state or 'Initializing' in state:
|
||
new_status = 'queued'
|
||
else:
|
||
new_status = state.lower()
|
||
|
||
# Update the download item with real-time data
|
||
download_item.update_status(
|
||
status=new_status,
|
||
progress=int(progress),
|
||
download_speed=int(avg_speed),
|
||
file_path=matching_transfer.get('filename', download_item.file_path)
|
||
)
|
||
|
||
# Update UI feedback for album track buttons if applicable
|
||
self.update_album_track_button_states(download_item, new_status)
|
||
|
||
# Store/update the download ID for future matching
|
||
transfer_id = matching_transfer.get('id', '')
|
||
if transfer_id and not download_item.download_id:
|
||
download_item.download_id = transfer_id
|
||
print(f"[DEBUG] Stored download ID for '{download_item.title}': {transfer_id}")
|
||
elif transfer_id and download_item.download_id != transfer_id:
|
||
# Update if we found a different/better ID
|
||
print(f"[DEBUG] Updated download ID for '{download_item.title}': {download_item.download_id} -> {transfer_id}")
|
||
download_item.download_id = transfer_id
|
||
|
||
# If no matching transfer found, the download might have been removed from slskd
|
||
else:
|
||
# Check if this download was in finished state and might have been removed
|
||
if download_item in self.download_queue.finished_queue.download_items:
|
||
print(f"[DEBUG] 🗑️ Download '{download_item.title}' not found in API - likely removed from slskd")
|
||
print(f"[DEBUG] Removing '{download_item.title}' from finished downloads UI")
|
||
# Remove from finished queue since it's no longer in slskd
|
||
self.download_queue.finished_queue.remove_download_item(download_item)
|
||
continue
|
||
|
||
# After processing all download items, check for any that weren't found in the API
|
||
# This handles the case where downloads were removed from slskd externally
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] Error processing transfer status update: {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}")
|
||
|
||
# Create and start transfer status update thread
|
||
status_thread = TransferStatusThread(self.soulseek_client)
|
||
status_thread.transfer_status_completed.connect(handle_status_update)
|
||
status_thread.transfer_status_failed.connect(lambda error: print(f"Transfer status update failed: {error}"))
|
||
|
||
# Track the thread to prevent garbage collection
|
||
self.status_update_threads.append(status_thread)
|
||
|
||
# Clean up old threads (keep only last 2 for efficiency)
|
||
if len(self.status_update_threads) > 2:
|
||
old_thread = self.status_update_threads.pop(0)
|
||
if old_thread.isRunning():
|
||
old_thread.stop()
|
||
old_thread.wait(1000)
|
||
old_thread.deleteLater()
|
||
|
||
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 |