diff --git a/web_server.py b/web_server.py
index 02d2b942..2f389216 100644
--- a/web_server.py
+++ b/web_server.py
@@ -6,8 +6,13 @@ import socket
import ipaddress
import subprocess
import platform
+import threading
+import time
+import shutil
+import glob
+from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
-from flask import Flask, render_template, request, jsonify, redirect
+from flask import Flask, render_template, request, jsonify, redirect, send_file
# --- Core Application Imports ---
# Import the same core clients and config manager used by the GUI app
@@ -41,6 +46,221 @@ except Exception as e:
print(f"đ´ FATAL: Error initializing service clients: {e}")
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = None
+# --- Global Streaming State Management ---
+# Thread-safe state tracking for streaming functionality
+stream_state = {
+ "status": "stopped", # States: stopped, loading, queued, ready, error
+ "progress": 0,
+ "track_info": None,
+ "file_path": None, # Path to the audio file in the 'Stream' folder
+ "error_message": None
+}
+stream_lock = threading.Lock() # Prevent race conditions
+stream_background_task = None
+stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time
+
+def _prepare_stream_task(track_data):
+ """
+ Background streaming task that downloads track to Stream folder and updates global state.
+ This replicates the logic from StreamingThread.run() in the GUI app.
+ """
+ try:
+ print(f"đĩ Starting stream preparation for: {track_data.get('filename')}")
+
+ # Update state to loading
+ with stream_lock:
+ stream_state.update({
+ "status": "loading",
+ "progress": 0,
+ "track_info": track_data,
+ "file_path": None,
+ "error_message": None
+ })
+
+ # Get paths
+ download_path = config_manager.get('soulseek.download_path', './downloads')
+ project_root = os.path.dirname(os.path.abspath(__file__)) # Web server root
+ 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)
+ print(f"đī¸ Cleared old stream file: {existing_file}")
+ except Exception as e:
+ print(f"â ī¸ Could not remove existing stream file: {e}")
+
+ # Start the download using the same mechanism as regular downloads
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ try:
+ download_result = loop.run_until_complete(soulseek_client.download(
+ track_data.get('username'),
+ track_data.get('filename'),
+ track_data.get('size', 0)
+ ))
+
+ if not download_result:
+ with stream_lock:
+ stream_state.update({
+ "status": "error",
+ "error_message": "Failed to initiate download"
+ })
+ return
+
+ # Poll for completion with progress updates
+ max_wait_time = 45 # Wait up to 45 seconds
+ poll_interval = 2 # Check every 2 seconds
+
+ for wait_count in range(max_wait_time // poll_interval):
+ # Check download progress via slskd API
+ try:
+ transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads'))
+ download_status = _find_streaming_download_in_transfers(transfers_data, track_data)
+
+ if download_status:
+ api_progress = download_status.get('percentComplete', 0)
+ download_state = download_status.get('state', '').lower()
+ original_state = download_status.get('state', '')
+
+ # Update progress
+ with stream_lock:
+ stream_state["progress"] = api_progress
+ if 'queued' in download_state or 'initializing' in download_state:
+ stream_state["status"] = "queued"
+ elif 'inprogress' in download_state:
+ stream_state["status"] = "loading"
+
+ # Check if download is complete
+ is_completed = ('Succeeded' in original_state or
+ ('Completed' in original_state and 'Errored' not in original_state) or
+ api_progress >= 100)
+
+ if is_completed:
+ print(f"â Download completed via API status: {original_state}")
+ # Try to find the actual file
+ found_file = _find_downloaded_file(download_path, track_data)
+
+ if found_file:
+ # Move file to Stream folder
+ original_filename = os.path.basename(found_file)
+ stream_path = os.path.join(stream_folder, original_filename)
+
+ shutil.move(found_file, stream_path)
+ print(f"â Moved file to stream folder: {stream_path}")
+
+ # Update state to ready
+ with stream_lock:
+ stream_state.update({
+ "status": "ready",
+ "progress": 100,
+ "file_path": stream_path
+ })
+
+ # Clean up download from slskd API
+ try:
+ download_id = download_status.get('id', '')
+ if download_id:
+ success = loop.run_until_complete(
+ soulseek_client.signal_download_completion(
+ download_id, track_data.get('username'), remove=True)
+ )
+ if success:
+ print(f"â Cleaned up download {download_id} from API")
+ except Exception as e:
+ print(f"â ī¸ Error cleaning up download: {e}")
+
+ return # Success!
+ else:
+ print("â Could not find downloaded file")
+ break
+
+ except Exception as e:
+ print(f"â ī¸ Error checking download progress: {e}")
+
+ # Wait before next poll
+ time.sleep(poll_interval)
+
+ # If we get here, download timed out
+ with stream_lock:
+ stream_state.update({
+ "status": "error",
+ "error_message": "Download timed out"
+ })
+
+ finally:
+ loop.close()
+
+ except Exception as e:
+ print(f"â Stream preparation failed: {e}")
+ with stream_lock:
+ stream_state.update({
+ "status": "error",
+ "error_message": str(e)
+ })
+
+def _find_streaming_download_in_transfers(transfers_data, track_data):
+ """Find streaming download in transfer data using same logic as download queue"""
+ try:
+ if not transfers_data:
+ return None
+
+ # 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'])
+
+ # Look for our specific file by filename and username
+ target_filename = os.path.basename(track_data.get('filename', ''))
+ target_username = track_data.get('username', '')
+
+ for transfer in all_transfers:
+ transfer_filename = os.path.basename(transfer.get('filename', ''))
+ transfer_username = transfer.get('username', '')
+
+ if (transfer_filename == target_filename and
+ transfer_username == target_username):
+ return transfer
+
+ return None
+ except Exception as e:
+ print(f"Error finding streaming download in transfers: {e}")
+ return None
+
+def _find_downloaded_file(download_path, track_data):
+ """Find the downloaded audio file in the downloads directory tree"""
+ audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
+ target_filename = os.path.basename(track_data.get('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
+
+ print(f"â Could not find downloaded file: {target_filename}")
+ return None
+
+ except Exception as e:
+ print(f"Error searching for downloaded file: {e}")
+ return None
+
# --- Refactored Logic from GUI Threads ---
# This logic is extracted from your QThread classes to be used directly by Flask.
@@ -678,25 +898,116 @@ def get_artists():
@app.route('/api/stream/start', methods=['POST'])
def stream_start():
- # Placeholder: simulates starting a media stream
+ """Start streaming a track in the background"""
+ global stream_background_task
+
data = request.get_json()
- print(f"Simulating stream start for: {data.get('filename')}")
- return jsonify({"success": True, "track": data})
+ if not data:
+ return jsonify({"success": False, "error": "No track data provided"}), 400
+
+ print(f"đĩ Web UI Stream request for: {data.get('filename')}")
+
+ try:
+ # Stop any existing streaming task
+ if stream_background_task and not stream_background_task.done():
+ stream_background_task.cancel()
+
+ # Reset stream state
+ with stream_lock:
+ stream_state.update({
+ "status": "stopped",
+ "progress": 0,
+ "track_info": None,
+ "file_path": None,
+ "error_message": None
+ })
+
+ # Start new background streaming task
+ stream_background_task = stream_executor.submit(_prepare_stream_task, data)
+
+ return jsonify({"success": True, "message": "Streaming started"})
+
+ except Exception as e:
+ print(f"â Error starting stream: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/stream/status')
def stream_status():
- # Placeholder: simulates stream status
- return jsonify({"status": "playing", "progress": 50, "track": {"title": "Bohemian Rhapsody"}})
+ """Get current streaming status and progress"""
+ try:
+ with stream_lock:
+ # Return copy of current stream state
+ return jsonify({
+ "status": stream_state["status"],
+ "progress": stream_state["progress"],
+ "track_info": stream_state["track_info"],
+ "error_message": stream_state["error_message"]
+ })
+ except Exception as e:
+ print(f"â Error getting stream status: {e}")
+ return jsonify({
+ "status": "error",
+ "progress": 0,
+ "track_info": None,
+ "error_message": str(e)
+ }), 500
-@app.route('/api/stream/toggle', methods=['POST'])
-def stream_toggle():
- # Placeholder: simulates toggling play/pause
- return jsonify({"playing": False})
+@app.route('/stream/audio')
+def stream_audio():
+ """Serve the audio file from the Stream folder"""
+ try:
+ with stream_lock:
+ if stream_state["status"] != "ready" or not stream_state["file_path"]:
+ return jsonify({"error": "No audio file ready for streaming"}), 404
+
+ file_path = stream_state["file_path"]
+
+ if not os.path.exists(file_path):
+ return jsonify({"error": "Audio file not found"}), 404
+
+ print(f"đĩ Serving audio file: {os.path.basename(file_path)}")
+ return send_file(file_path, as_attachment=False)
+
+ except Exception as e:
+ print(f"â Error serving audio file: {e}")
+ return jsonify({"error": str(e)}), 500
@app.route('/api/stream/stop', methods=['POST'])
def stream_stop():
- # Placeholder: simulates stopping a stream
- return jsonify({"success": True})
+ """Stop streaming and clean up"""
+ global stream_background_task
+
+ try:
+ # Cancel background task
+ if stream_background_task and not stream_background_task.done():
+ stream_background_task.cancel()
+
+ # Clear Stream folder
+ project_root = os.path.dirname(os.path.abspath(__file__))
+ 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):
+ os.remove(file_path)
+ print(f"đī¸ Removed stream file: {filename}")
+
+ # Reset stream state
+ with stream_lock:
+ stream_state.update({
+ "status": "stopped",
+ "progress": 0,
+ "track_info": None,
+ "file_path": None,
+ "error_message": None
+ })
+
+ return jsonify({"success": True, "message": "Stream stopped"})
+
+ except Exception as e:
+ print(f"â Error stopping stream: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/version-info', methods=['GET'])
def get_version_info():
diff --git a/webui/index.html b/webui/index.html
index 13935d66..4a86f917 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -583,6 +583,9 @@
+
+
+
diff --git a/webui/static/script.js b/webui/static/script.js
index d328242d..2d77fd33 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -12,6 +12,10 @@ let currentStream = {
progress: 0,
track: null
};
+
+// Streaming state management (new functionality)
+let streamStatusPoller = null;
+let audioPlayer = null;
let allSearchResults = [];
let currentFilterType = 'all';
let currentFilterFormat = 'all';
@@ -57,6 +61,17 @@ document.addEventListener('DOMContentLoaded', function() {
// Load initial data
loadInitialData();
+ // Handle window resize to re-check track title scrolling
+ window.addEventListener('resize', function() {
+ if (currentTrack) {
+ const trackTitleElement = document.getElementById('track-title');
+ const trackTitle = currentTrack.title || 'Unknown Track';
+ setTimeout(() => {
+ checkAndEnableScrolling(trackTitleElement, trackTitle);
+ }, 100); // Small delay to allow layout to settle
+ }
+ });
+
console.log('SoulSync WebUI initialized successfully!');
});
@@ -182,6 +197,21 @@ function initializeMediaPlayer() {
const stopButton = document.getElementById('stop-button');
const volumeSlider = document.getElementById('volume-slider');
+ // Initialize HTML5 audio player
+ audioPlayer = document.getElementById('audio-player');
+ if (audioPlayer) {
+ // Set up audio event listeners
+ audioPlayer.addEventListener('timeupdate', updateAudioProgress);
+ audioPlayer.addEventListener('ended', onAudioEnded);
+ audioPlayer.addEventListener('error', onAudioError);
+ audioPlayer.addEventListener('loadstart', onAudioLoadStart);
+ audioPlayer.addEventListener('canplay', onAudioCanPlay);
+
+ // Set initial volume
+ audioPlayer.volume = 0.7; // 70%
+ volumeSlider.value = 70;
+ }
+
// Track title click - toggle expansion
trackTitle.addEventListener('click', toggleMediaPlayerExpansion);
@@ -192,9 +222,6 @@ function initializeMediaPlayer() {
// Update volume slider styling
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
-
- // Start stream status polling if needed
- setInterval(updateStreamStatus, 1000);
}
function toggleMediaPlayerExpansion() {
@@ -216,13 +243,44 @@ function toggleMediaPlayerExpansion() {
}
}
+function extractTrackTitle(filename) {
+ if (!filename) return null;
+
+ // Remove file extension
+ let title = filename.replace(/\.[^/.]+$/, '');
+
+ // Remove path components, keep only the filename
+ title = title.split('/').pop().split('\\').pop();
+
+ // Clean up common filename patterns
+ title = title
+ .replace(/^\d+\.?\s*/, '') // Remove track numbers at start
+ .replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns
+ .replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end
+ .replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info
+ .replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end
+ .trim();
+
+ return title || null;
+}
+
function setTrackInfo(track) {
currentTrack = track;
- document.getElementById('track-title').textContent = track.title || 'Unknown Track';
+ const trackTitleElement = document.getElementById('track-title');
+ const trackTitle = track.title || 'Unknown Track';
+
+ // Set up the HTML structure for scrolling
+ trackTitleElement.innerHTML = `
${escapeHtml(trackTitle)}`;
+
document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist';
document.getElementById('album-name').textContent = track.album || 'Unknown Album';
+ // Check if title needs scrolling (similar to GUI app)
+ setTimeout(() => {
+ checkAndEnableScrolling(trackTitleElement, trackTitle);
+ }, 100); // Allow DOM to settle
+
// Enable controls
document.getElementById('play-button').disabled = false;
document.getElementById('stop-button').disabled = false;
@@ -236,11 +294,42 @@ function setTrackInfo(track) {
}
}
+function checkAndEnableScrolling(element, text) {
+ // Remove any existing scrolling class and reset styles
+ element.classList.remove('scrolling');
+ element.style.removeProperty('--scroll-distance');
+
+ // Force a layout to get accurate measurements
+ element.offsetWidth;
+
+ // Get the inner text element
+ const titleTextElement = element.querySelector('.title-text');
+ if (!titleTextElement) return;
+
+ // Check if text is wider than container
+ const containerWidth = element.offsetWidth;
+ const textWidth = titleTextElement.scrollWidth;
+
+ // Enable scrolling if text is significantly wider than container
+ if (textWidth > containerWidth + 15) {
+ const scrollDistance = containerWidth - textWidth;
+ element.style.setProperty('--scroll-distance', `${scrollDistance}px`);
+ element.classList.add('scrolling');
+ console.log(`đ Enabled scrolling for title: "${text}"`);
+ console.log(`đ Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`);
+ }
+}
+
+
function clearTrack() {
currentTrack = null;
isPlaying = false;
- document.getElementById('track-title').textContent = 'No track';
+ const trackTitleElement = document.getElementById('track-title');
+ trackTitleElement.innerHTML = '
No track';
+ trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation
+ trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable
+
document.getElementById('artist-name').textContent = 'Unknown Artist';
document.getElementById('album-name').textContent = 'Unknown Album';
document.getElementById('play-button').textContent = 'âˇ';
@@ -264,50 +353,24 @@ function setPlayingState(playing) {
}
async function handlePlayPause() {
- if (!currentTrack) return;
-
- try {
- const response = await fetch(API.stream.toggle, { method: 'POST' });
- const data = await response.json();
-
- if (data.error) {
- showToast(`Playback error: ${data.error}`, 'error');
- } else {
- setPlayingState(data.playing);
- }
- } catch (error) {
- console.error('Error toggling playback:', error);
- showToast('Failed to toggle playback', 'error');
- }
+ // Use new streaming system toggle function
+ togglePlayback();
}
async function handleStop() {
- try {
- const response = await fetch(API.stream.stop, { method: 'POST' });
- const data = await response.json();
-
- if (data.error) {
- showToast(`Stop error: ${data.error}`, 'error');
- } else {
- clearTrack();
- currentStream.status = 'stopped';
- }
- } catch (error) {
- console.error('Error stopping playback:', error);
- showToast('Failed to stop playback', 'error');
- }
+ // Use new streaming system stop function
+ await stopStream();
+ clearTrack();
}
function handleVolumeChange(event) {
const volume = event.target.value;
updateVolumeSliderAppearance();
- // Send volume change to backend
- fetch('/api/media/volume', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ volume: volume / 100 })
- }).catch(error => console.error('Error setting volume:', error));
+ // Update HTML5 audio player volume
+ if (audioPlayer) {
+ audioPlayer.volume = volume / 100;
+ }
}
function updateVolumeSliderAppearance() {
@@ -334,36 +397,293 @@ function setLoadingProgress(percentage) {
loadingText.textContent = `${Math.round(percentage)}%`;
}
-async function updateStreamStatus() {
+// ===============================
+// STREAMING FUNCTIONALITY
+// ===============================
+
+async function startStream(searchResult) {
+ // Start streaming a track - handles same track toggle and new track streaming
try {
- const response = await fetch(API.stream.status);
+ console.log(`đŽ startStream() called with data:`, searchResult);
+
+ // Check if this is the same track that's currently playing/loading
+ const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null;
+ const newTrackId = `${searchResult.username}:${searchResult.filename}`;
+
+ console.log(`đŽ startStream() called for: ${searchResult.filename}`);
+ console.log(`đŽ Current track ID: ${currentTrackId}`);
+ console.log(`đŽ New track ID: ${newTrackId}`);
+
+ if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) {
+ // Same track clicked while playing - toggle pause
+ console.log("đ Toggling playback for same track");
+ togglePlayback();
+ return;
+ }
+
+ // Different track or no current track - start new stream
+ console.log("đĩ Starting new stream");
+
+ // Stop current streaming/playback if any
+ await stopStream();
+
+ // Set track info and show loading state
+ setTrackInfo({
+ title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track',
+ artist: searchResult.artist || searchResult.username || 'Unknown Artist',
+ album: searchResult.album || 'Unknown Album',
+ username: searchResult.username,
+ filename: searchResult.filename
+ });
+
+ showLoadingAnimation();
+ setLoadingProgress(0);
+
+ // Start streaming request
+ const response = await fetch(API.stream.start, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(searchResult)
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
const data = await response.json();
- if (data.track && currentStream.status !== data.status) {
- currentStream = data;
-
- switch (data.status) {
- case 'loading':
- setLoadingProgress(data.progress);
- break;
- case 'playing':
- hideLoadingAnimation();
- setPlayingState(true);
- break;
- case 'paused':
- hideLoadingAnimation();
- setPlayingState(false);
- break;
- case 'stopped':
- clearTrack();
- break;
- }
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to start streaming');
}
+
+ console.log("â
Stream started successfully");
+
+ // Start status polling
+ startStreamStatusPolling();
+
} catch (error) {
- // Don't log errors for stream status - it's expected when no stream is active
+ console.error('Error starting stream:', error);
+ showToast(`Failed to start stream: ${error.message}`, 'error');
+ hideLoadingAnimation();
+ clearTrack();
}
}
+function startStreamStatusPolling() {
+ // Start polling for stream status updates
+ if (streamStatusPoller) {
+ clearInterval(streamStatusPoller);
+ }
+
+ console.log('đ Starting stream status polling (1-second interval)');
+ updateStreamStatus(); // Initial check
+ streamStatusPoller = setInterval(updateStreamStatus, 1000);
+}
+
+function stopStreamStatusPolling() {
+ // Stop polling for stream status updates
+ if (streamStatusPoller) {
+ clearInterval(streamStatusPoller);
+ streamStatusPoller = null;
+ console.log('âšī¸ Stopped stream status polling');
+ }
+}
+
+async function updateStreamStatus() {
+ // Poll server for streaming progress and handle state changes
+ try {
+ const response = await fetch(API.stream.status);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // Update current stream state
+ currentStream.status = data.status;
+ currentStream.progress = data.progress;
+
+ switch (data.status) {
+ case 'loading':
+ setLoadingProgress(data.progress);
+ break;
+
+ case 'queued':
+ // Show queue status
+ const loadingText = document.querySelector('.loading-text');
+ if (loadingText) {
+ loadingText.textContent = 'Queued...';
+ }
+ break;
+
+ case 'ready':
+ // Stream is ready - start audio playback
+ console.log('đĩ Stream ready, starting audio playback');
+ stopStreamStatusPolling();
+ await startAudioPlayback();
+ break;
+
+ case 'error':
+ console.error('â Streaming error:', data.error_message);
+ stopStreamStatusPolling();
+ hideLoadingAnimation();
+ showToast(`Streaming error: ${data.error_message}`, 'error');
+ clearTrack();
+ break;
+ }
+
+ } catch (error) {
+ console.error('Error updating stream status:', error);
+ // Don't clear everything on network errors - might be temporary
+ }
+}
+
+async function startAudioPlayback() {
+ // Start HTML5 audio playback of the streamed file
+ try {
+ if (!audioPlayer) {
+ throw new Error('Audio player not initialized');
+ }
+
+ // Set audio source with cache-busting timestamp
+ const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
+ audioPlayer.src = audioUrl;
+
+ console.log(`đĩ Loading audio from: ${audioUrl}`);
+
+ // Start playback
+ await audioPlayer.play();
+
+ // Update UI to playing state
+ hideLoadingAnimation();
+ setPlayingState(true);
+
+ console.log('â
Audio playback started successfully');
+
+ } catch (error) {
+ console.error('â Error starting audio playback:', error);
+ hideLoadingAnimation();
+ showToast(`Playback error: ${error.message}`, 'error');
+ clearTrack();
+ }
+}
+
+async function stopStream() {
+ // Stop streaming and clean up all state
+ try {
+ // Stop status polling
+ stopStreamStatusPolling();
+
+ // Stop audio playback
+ if (audioPlayer) {
+ audioPlayer.pause();
+ audioPlayer.src = '';
+ }
+
+ // Call backend stop endpoint
+ const response = await fetch(API.stream.stop, { method: 'POST' });
+ if (response.ok) {
+ const data = await response.json();
+ console.log('đ Stream stopped:', data.message);
+ }
+
+ // Reset UI state
+ hideLoadingAnimation();
+ setPlayingState(false);
+
+ // Reset stream state
+ currentStream = {
+ status: 'stopped',
+ progress: 0,
+ track: null
+ };
+
+ } catch (error) {
+ console.error('Error stopping stream:', error);
+ }
+}
+
+function togglePlayback() {
+ // Toggle play/pause for currently loaded audio
+ if (!audioPlayer || !currentTrack) {
+ console.log('â ī¸ No audio player or track to toggle');
+ return;
+ }
+
+ if (audioPlayer.paused) {
+ audioPlayer.play()
+ .then(() => {
+ setPlayingState(true);
+ console.log('âļī¸ Resumed playback');
+ })
+ .catch(error => {
+ console.error('Error resuming playback:', error);
+ showToast('Failed to resume playback', 'error');
+ });
+ } else {
+ audioPlayer.pause();
+ setPlayingState(false);
+ console.log('â¸ī¸ Paused playback');
+ }
+}
+
+// ===============================
+// AUDIO EVENT HANDLERS
+// ===============================
+
+function updateAudioProgress() {
+ // Update progress bar based on audio playback time
+ if (!audioPlayer || !audioPlayer.duration) return;
+
+ const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
+ // TODO: Update progress bar in sidebar when implemented
+
+ // Update time display if elements exist
+ const currentTimeElement = document.getElementById('current-time');
+ const totalTimeElement = document.getElementById('total-time');
+
+ if (currentTimeElement) {
+ currentTimeElement.textContent = formatTime(audioPlayer.currentTime);
+ }
+ if (totalTimeElement) {
+ totalTimeElement.textContent = formatTime(audioPlayer.duration);
+ }
+}
+
+function onAudioEnded() {
+ // Handle audio playback completion
+ console.log('đ Audio playback ended');
+ setPlayingState(false);
+ // TODO: Auto-advance to next track if queue exists
+}
+
+function onAudioError(event) {
+ // Handle audio playback errors
+ console.error('â Audio error:', event.target.error);
+ hideLoadingAnimation();
+ showToast('Audio playback error occurred', 'error');
+ clearTrack();
+}
+
+function onAudioLoadStart() {
+ // Handle audio load start
+ console.log('đ Audio loading started');
+}
+
+function onAudioCanPlay() {
+ // Handle when audio can start playing
+ console.log('â
Audio ready to play');
+}
+
+function formatTime(seconds) {
+ // Format seconds as MM:SS
+ if (!seconds || !isFinite(seconds)) return '0:00';
+
+ const minutes = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+}
+
// ===============================
// DONATION WIDGET
// ===============================
@@ -790,7 +1110,7 @@ function displaySearchResults(results) {
${result.album ? `
${escapeHtml(result.album)}
` : ''}
-
+ âļ
âŦ
@@ -1335,6 +1612,7 @@ function displayDownloadsResults(results) {
Shared by ${escapeHtml(result.username || 'Unknown')}
+ âļ Stream
âŦ Download
@@ -1448,6 +1726,66 @@ async function downloadAlbumTrack(albumIndex, trackIndex) {
}
}
+// ===============================
+// STREAMING WRAPPER FUNCTIONS
+// ===============================
+
+async function streamTrack(index) {
+ // Stream a single track from search results
+ try {
+ console.log(`đĩ streamTrack called with index: ${index}`);
+ console.log(`đĩ window.currentSearchResults:`, window.currentSearchResults);
+
+ if (!window.currentSearchResults || !window.currentSearchResults[index]) {
+ console.error(`â No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
+ showToast('Track not found', 'error');
+ return;
+ }
+
+ const result = window.currentSearchResults[index];
+ console.log(`đĩ Streaming track:`, result);
+
+ await startStream(result);
+
+ } catch (error) {
+ console.error('Track streaming error:', error);
+ showToast('Failed to start track stream', 'error');
+ }
+}
+
+
+async function streamAlbumTrack(albumIndex, trackIndex) {
+ // Stream a specific track from an album
+ try {
+ console.log(`đĩ streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`);
+ console.log(`đĩ window.currentSearchResults:`, window.currentSearchResults);
+
+ if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) {
+ console.error(`â No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
+ showToast('Album not found', 'error');
+ return;
+ }
+
+ const album = window.currentSearchResults[albumIndex];
+ console.log(`đĩ Album data:`, album);
+
+ if (!album.tracks || !album.tracks[trackIndex]) {
+ console.error(`â No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`);
+ showToast('Track not found in album', 'error');
+ return;
+ }
+
+ const track = album.tracks[trackIndex];
+ console.log(`đĩ Streaming album track:`, track);
+
+ await startStream(track);
+
+ } catch (error) {
+ console.error('Album track streaming error:', error);
+ showToast('Failed to start track stream', 'error');
+ }
+}
+
async function loadArtistsData() {
try {
const response = await fetch(API.artists);
@@ -1797,6 +2135,8 @@ window.authenticateTidal = authenticateTidal;
window.browsePath = browsePath;
window.selectResult = selectResult;
window.startStream = startStream;
+window.streamTrack = streamTrack;
+window.streamAlbumTrack = streamAlbumTrack;
window.startDownload = startDownload;
window.downloadTrack = downloadTrack;
window.downloadAlbum = downloadAlbum;
diff --git a/webui/static/style.css b/webui/static/style.css
index e0b26e73..0739d53e 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -206,45 +206,80 @@ body {
.media-header {
display: flex;
- align-items: center;
- gap: 14px;
- margin-bottom: 8px;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 10px;
}
.media-info {
flex: 1;
min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-top: 2px;
}
.track-title {
font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, sans-serif;
- font-size: 14px;
+ font-size: 15px;
font-weight: 700;
color: #ffffff;
- letter-spacing: -0.3px;
- line-height: 1.2;
- margin-bottom: 2px;
+ letter-spacing: -0.2px;
+ line-height: 1.3;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
- text-overflow: ellipsis;
+ margin: 0;
+ transition: color 0.2s ease;
+ position: relative;
+ width: 100%;
+}
+
+.track-title .title-text {
+ display: inline-block;
+ transition: transform 0.3s ease;
+}
+
+.track-title.scrolling .title-text {
+ animation: marquee-scroll 10s linear infinite;
+ animation-delay: 1.5s;
+}
+
+.track-title.scrolling:hover .title-text {
+ animation-play-state: paused;
+}
+
+@keyframes marquee-scroll {
+ 0% {
+ transform: translateX(0px);
+ }
+ 20% {
+ transform: translateX(0px);
+ }
+ 80% {
+ transform: translateX(var(--scroll-distance));
+ }
+ 100% {
+ transform: translateX(var(--scroll-distance));
+ }
}
.track-title:hover {
color: #1ed760;
- text-decoration: underline;
}
.artist-name {
font-family: 'Spotify Circular', -apple-system, sans-serif;
- font-size: 11px;
- font-weight: 400;
- color: #b3b3b3;
+ font-size: 12px;
+ font-weight: 500;
+ color: #a7a7a7;
letter-spacing: 0.1px;
- margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ margin: 0;
+ opacity: 0.9;
}
.play-button {
@@ -280,26 +315,30 @@ body {
}
.media-expanded {
- margin-top: 2px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.album-name {
font-family: 'Spotify Circular', -apple-system, sans-serif;
font-size: 11px;
- font-weight: 400;
- color: #a7a7a7;
+ font-weight: 500;
+ color: #999999;
letter-spacing: 0.1px;
- margin-bottom: 4px;
+ margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ font-style: italic;
+ opacity: 0.8;
}
.media-controls {
display: flex;
align-items: center;
justify-content: space-between;
- margin-top: 6px;
+ margin-top: 0px;
}
.volume-control {
@@ -378,14 +417,15 @@ body {
}
.no-track-message {
- color: #6a6a6a;
- font-size: 12px;
- font-weight: 400;
+ color: #888888;
+ font-size: 13px;
+ font-weight: 500;
text-align: center;
- padding: 20px 16px;
- letter-spacing: 0.2px;
+ padding: 24px 20px;
+ letter-spacing: 0.1px;
font-family: 'Spotify Circular', -apple-system, sans-serif;
- line-height: 1.4;
+ line-height: 1.5;
+ opacity: 0.85;
}
/* Crypto Donation Section - Matching CryptoDonationWidget */
@@ -2575,4 +2615,92 @@ body {
padding: 0;
font-size: 14px;
}
+
+/* ===============================
+ STREAMING BUTTON STYLES
+ =============================== */
+
+/* Track stream buttons */
+.track-stream-btn {
+ background: linear-gradient(to bottom, rgba(29, 185, 84, 0.1), rgba(25, 155, 71, 0.2));
+ border: 1px solid rgba(29, 185, 84, 0.4);
+ color: rgba(29, 185, 84, 0.9);
+ border-radius: 6px;
+ padding: 4px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ height: 24px;
+}
+
+.track-stream-btn:hover {
+ background: linear-gradient(to bottom, rgba(29, 185, 84, 0.2), rgba(25, 155, 71, 0.3));
+ border-color: rgba(29, 185, 84, 0.6);
+ color: rgba(29, 185, 84, 1);
+ transform: scale(1.05);
+}
+
+/* Album stream buttons */
+.album-stream-btn {
+ background: linear-gradient(to bottom, rgba(29, 185, 84, 0.1), rgba(25, 155, 71, 0.2));
+ border: 1px solid rgba(29, 185, 84, 0.4);
+ color: rgba(29, 185, 84, 0.9);
+ border-radius: 8px;
+ padding: 8px 16px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-right: 8px;
+}
+
+.album-stream-btn:hover {
+ background: linear-gradient(to bottom, rgba(29, 185, 84, 0.2), rgba(25, 155, 71, 0.3));
+ border-color: rgba(29, 185, 84, 0.6);
+ color: rgba(29, 185, 84, 1);
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2);
+}
+
+/* Loading states for stream buttons */
+.track-stream-btn:disabled,
+.album-stream-btn:disabled {
+ background: linear-gradient(to bottom, rgba(120, 120, 120, 0.2), rgba(100, 100, 100, 0.3));
+ border-color: rgba(120, 120, 120, 0.3);
+ color: rgba(255, 255, 255, 0.4);
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* Active/playing states */
+.track-stream-btn.playing,
+.album-stream-btn.playing {
+ background: linear-gradient(to bottom, rgba(29, 185, 84, 0.3), rgba(25, 155, 71, 0.4));
+ border-color: rgba(29, 185, 84, 0.8);
+ color: #ffffff;
+ box-shadow: 0 0 8px rgba(29, 185, 84, 0.4);
+}
+
+.track-stream-btn.loading,
+.album-stream-btn.loading {
+ background: linear-gradient(to bottom, rgba(255, 193, 7, 0.2), rgba(255, 152, 0, 0.3));
+ border-color: rgba(255, 193, 7, 0.5);
+ color: rgba(255, 193, 7, 0.9);
+ animation: pulse-loading 1.5s infinite;
+}
+
+@keyframes pulse-loading {
+ 0% { opacity: 0.7; }
+ 50% { opacity: 1; }
+ 100% { opacity: 0.7; }
+}
+
/* END OF CSS SNIPPET */
\ No newline at end of file