stream
This commit is contained in:
parent
58c00831bc
commit
c5617d874e
4 changed files with 535 additions and 74 deletions
251
web_server.py
251
web_server.py
|
|
@ -15,7 +15,7 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, send_file
|
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response
|
||||||
|
|
||||||
# --- Core Application Imports ---
|
# --- Core Application Imports ---
|
||||||
# Import the same core clients and config manager used by the GUI app
|
# Import the same core clients and config manager used by the GUI app
|
||||||
|
|
@ -481,8 +481,13 @@ album_name_cache = {} # album_key -> cached_final_name
|
||||||
def _prepare_stream_task(track_data):
|
def _prepare_stream_task(track_data):
|
||||||
"""
|
"""
|
||||||
Background streaming task that downloads track to Stream folder and updates global state.
|
Background streaming task that downloads track to Stream folder and updates global state.
|
||||||
This replicates the logic from StreamingThread.run() in the GUI app.
|
Enhanced version with robust error handling matching the GUI StreamingThread.
|
||||||
"""
|
"""
|
||||||
|
loop = None
|
||||||
|
queue_start_time = None
|
||||||
|
actively_downloading = False
|
||||||
|
last_progress_sent = 0.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"🎵 Starting stream preparation for: {track_data.get('filename')}")
|
print(f"🎵 Starting stream preparation for: {track_data.get('filename')}")
|
||||||
|
|
||||||
|
|
@ -498,7 +503,7 @@ def _prepare_stream_task(track_data):
|
||||||
|
|
||||||
# Get paths
|
# Get paths
|
||||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||||
project_root = os.path.dirname(os.path.abspath(__file__)) # Web server root
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
stream_folder = os.path.join(project_root, 'Stream')
|
stream_folder = os.path.join(project_root, 'Stream')
|
||||||
|
|
||||||
# Ensure Stream directory exists
|
# Ensure Stream directory exists
|
||||||
|
|
@ -530,16 +535,26 @@ def _prepare_stream_task(track_data):
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
stream_state.update({
|
stream_state.update({
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error_message": "Failed to initiate download"
|
"error_message": "Failed to initiate download - uploader may be offline"
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Poll for completion with progress updates
|
print(f"✓ Download initiated for streaming")
|
||||||
max_wait_time = 45 # Wait up to 45 seconds
|
|
||||||
poll_interval = 2 # Check every 2 seconds
|
# Enhanced monitoring with queue timeout detection (matching GUI)
|
||||||
|
max_wait_time = 60 # Increased timeout
|
||||||
|
poll_interval = 1.5 # More frequent polling
|
||||||
|
queue_timeout = 15 # Queue timeout like GUI
|
||||||
|
wait_count = 0
|
||||||
|
|
||||||
|
while wait_count * poll_interval < max_wait_time:
|
||||||
|
wait_count += 1
|
||||||
|
|
||||||
for wait_count in range(max_wait_time // poll_interval):
|
|
||||||
# Check download progress via slskd API
|
# Check download progress via slskd API
|
||||||
|
api_progress = None
|
||||||
|
download_state = None
|
||||||
|
download_status = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads'))
|
transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads'))
|
||||||
download_status = _find_streaming_download_in_transfers(transfers_data, track_data)
|
download_status = _find_streaming_download_in_transfers(transfers_data, track_data)
|
||||||
|
|
@ -549,80 +564,159 @@ def _prepare_stream_task(track_data):
|
||||||
download_state = download_status.get('state', '').lower()
|
download_state = download_status.get('state', '').lower()
|
||||||
original_state = download_status.get('state', '')
|
original_state = download_status.get('state', '')
|
||||||
|
|
||||||
# Update progress
|
print(f"API Download - State: {original_state}, Progress: {api_progress:.1f}%")
|
||||||
with stream_lock:
|
|
||||||
stream_state["progress"] = api_progress
|
# Track queue state timing (matching GUI logic)
|
||||||
if 'queued' in download_state or 'initializing' in download_state:
|
is_queued = ('queued' in download_state or 'initializing' in download_state)
|
||||||
|
is_downloading = ('inprogress' in download_state or 'transferring' in download_state)
|
||||||
|
is_completed = ('succeeded' in download_state or api_progress >= 100)
|
||||||
|
|
||||||
|
# Handle queue state timing
|
||||||
|
if is_queued and queue_start_time is None:
|
||||||
|
queue_start_time = time.time()
|
||||||
|
print(f"📋 Download entered queue state: {original_state}")
|
||||||
|
with stream_lock:
|
||||||
stream_state["status"] = "queued"
|
stream_state["status"] = "queued"
|
||||||
elif 'inprogress' in download_state:
|
elif is_downloading and not actively_downloading:
|
||||||
|
actively_downloading = True
|
||||||
|
queue_start_time = None # Reset queue timer
|
||||||
|
print(f"🚀 Download started actively downloading: {original_state}")
|
||||||
|
with stream_lock:
|
||||||
stream_state["status"] = "loading"
|
stream_state["status"] = "loading"
|
||||||
|
|
||||||
# Check if download is complete
|
# Check for queue timeout (matching GUI)
|
||||||
is_completed = ('Succeeded' in original_state or
|
if is_queued and queue_start_time:
|
||||||
('Completed' in original_state and 'Errored' not in original_state) or
|
queue_elapsed = time.time() - queue_start_time
|
||||||
api_progress >= 100)
|
if queue_elapsed > queue_timeout:
|
||||||
|
print(f"⏰ Queue timeout after {queue_elapsed:.1f}s - download stuck in queue")
|
||||||
|
with stream_lock:
|
||||||
|
stream_state.update({
|
||||||
|
"status": "error",
|
||||||
|
"error_message": "Queue timeout - uploader not responding. Try another source."
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
with stream_lock:
|
||||||
|
if api_progress != last_progress_sent:
|
||||||
|
stream_state["progress"] = api_progress
|
||||||
|
last_progress_sent = api_progress
|
||||||
|
|
||||||
|
# Check if download is complete
|
||||||
if is_completed:
|
if is_completed:
|
||||||
print(f"✓ Download completed via API status: {original_state}")
|
print(f"✓ Download completed via API status: {original_state}")
|
||||||
# Try to find the actual file
|
|
||||||
|
# Give file system time to sync
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
found_file = _find_downloaded_file(download_path, track_data)
|
found_file = _find_downloaded_file(download_path, track_data)
|
||||||
|
|
||||||
|
# Retry file search a few times (matching GUI logic)
|
||||||
|
retry_attempts = 5
|
||||||
|
for attempt in range(retry_attempts):
|
||||||
|
if found_file:
|
||||||
|
break
|
||||||
|
print(f"File not found yet, attempt {attempt + 1}/{retry_attempts}")
|
||||||
|
time.sleep(1)
|
||||||
|
found_file = _find_downloaded_file(download_path, track_data)
|
||||||
|
|
||||||
if found_file:
|
if found_file:
|
||||||
|
print(f"✓ Found downloaded file: {found_file}")
|
||||||
|
|
||||||
# Move file to Stream folder
|
# Move file to Stream folder
|
||||||
original_filename = os.path.basename(found_file)
|
original_filename = os.path.basename(found_file)
|
||||||
stream_path = os.path.join(stream_folder, original_filename)
|
stream_path = os.path.join(stream_folder, original_filename)
|
||||||
|
|
||||||
shutil.move(found_file, stream_path)
|
try:
|
||||||
print(f"✓ Moved file to stream folder: {stream_path}")
|
shutil.move(found_file, stream_path)
|
||||||
|
print(f"✓ Moved file to stream folder: {stream_path}")
|
||||||
|
|
||||||
# Update state to ready
|
# Clean up empty directories (matching GUI)
|
||||||
|
_cleanup_empty_directories(download_path, found_file)
|
||||||
|
|
||||||
|
# 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 and track_data.get('username'):
|
||||||
|
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}")
|
||||||
|
|
||||||
|
print(f"✅ Stream file ready for playback: {stream_path}")
|
||||||
|
return # Success!
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error moving file to stream folder: {e}")
|
||||||
|
with stream_lock:
|
||||||
|
stream_state.update({
|
||||||
|
"status": "error",
|
||||||
|
"error_message": f"Failed to prepare stream file: {e}"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print("❌ Could not find downloaded file after completion")
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
stream_state.update({
|
stream_state.update({
|
||||||
"status": "ready",
|
"status": "error",
|
||||||
"progress": 100,
|
"error_message": "Download completed but file not found"
|
||||||
"file_path": stream_path
|
|
||||||
})
|
})
|
||||||
|
return
|
||||||
# Clean up download from slskd API
|
else:
|
||||||
try:
|
# No transfer found in API - may still be initializing
|
||||||
download_id = download_status.get('id', '')
|
print(f"No transfer found in API yet... (elapsed: {wait_count * poll_interval}s)")
|
||||||
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:
|
except Exception as e:
|
||||||
print(f"⚠️ Error checking download progress: {e}")
|
print(f"⚠️ Error checking download progress: {e}")
|
||||||
|
# Continue to next iteration if API call fails
|
||||||
|
|
||||||
# Wait before next poll
|
# Wait before next poll
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
# If we get here, download timed out
|
# If we get here, download timed out
|
||||||
|
print(f"❌ Download timed out after {max_wait_time}s")
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
stream_state.update({
|
stream_state.update({
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error_message": "Download timed out"
|
"error_message": "Download timed out - try a different source"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print("🛑 Stream task cancelled")
|
||||||
|
with stream_lock:
|
||||||
|
stream_state.update({
|
||||||
|
"status": "stopped",
|
||||||
|
"error_message": None
|
||||||
|
})
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
if loop:
|
||||||
|
try:
|
||||||
|
# Clean up any pending tasks
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
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}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Stream preparation failed: {e}")
|
print(f"❌ Stream preparation failed: {e}")
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
stream_state.update({
|
stream_state.update({
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error_message": str(e)
|
"error_message": f"Streaming error: {str(e)}"
|
||||||
})
|
})
|
||||||
|
|
||||||
def _find_streaming_download_in_transfers(transfers_data, track_data):
|
def _find_streaming_download_in_transfers(transfers_data, track_data):
|
||||||
|
|
@ -1500,7 +1594,7 @@ def stream_status():
|
||||||
|
|
||||||
@app.route('/stream/audio')
|
@app.route('/stream/audio')
|
||||||
def stream_audio():
|
def stream_audio():
|
||||||
"""Serve the audio file from the Stream folder"""
|
"""Serve the audio file from the Stream folder with range request support"""
|
||||||
try:
|
try:
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
||||||
|
|
@ -1525,9 +1619,70 @@ def stream_audio():
|
||||||
'.wma': 'audio/x-ms-wma'
|
'.wma': 'audio/x-ms-wma'
|
||||||
}
|
}
|
||||||
|
|
||||||
mimetype = mime_types.get(file_ext, 'audio/mpeg') # Default to MP3
|
mimetype = mime_types.get(file_ext, 'audio/mpeg')
|
||||||
|
|
||||||
return send_file(file_path, as_attachment=False, mimetype=mimetype)
|
# Get file size
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# Handle range requests (important for HTML5 audio seeking)
|
||||||
|
range_header = request.headers.get('Range', None)
|
||||||
|
if range_header:
|
||||||
|
byte_start = 0
|
||||||
|
byte_end = file_size - 1
|
||||||
|
|
||||||
|
# Parse range header (format: "bytes=start-end")
|
||||||
|
try:
|
||||||
|
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
|
||||||
|
if range_match:
|
||||||
|
start_str, end_str = range_match.groups()
|
||||||
|
if start_str:
|
||||||
|
byte_start = int(start_str)
|
||||||
|
if end_str:
|
||||||
|
byte_end = int(end_str)
|
||||||
|
else:
|
||||||
|
# If no end specified, serve from start to end of file
|
||||||
|
byte_end = file_size - 1
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
# Invalid range header, serve full file
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure valid range
|
||||||
|
byte_start = max(0, byte_start)
|
||||||
|
byte_end = min(file_size - 1, byte_end)
|
||||||
|
content_length = byte_end - byte_start + 1
|
||||||
|
|
||||||
|
# Create response with partial content
|
||||||
|
def generate():
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
f.seek(byte_start)
|
||||||
|
remaining = content_length
|
||||||
|
while remaining:
|
||||||
|
chunk_size = min(8192, remaining) # 8KB chunks
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
remaining -= len(chunk)
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
response = Response(generate(),
|
||||||
|
status=206, # Partial Content
|
||||||
|
mimetype=mimetype,
|
||||||
|
direct_passthrough=True)
|
||||||
|
|
||||||
|
# Set range headers
|
||||||
|
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
|
||||||
|
response.headers.add('Accept-Ranges', 'bytes')
|
||||||
|
response.headers.add('Content-Length', str(content_length))
|
||||||
|
response.headers.add('Cache-Control', 'no-cache')
|
||||||
|
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
# No range request, serve entire file
|
||||||
|
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
|
||||||
|
response.headers.add('Accept-Ranges', 'bytes')
|
||||||
|
response.headers.add('Content-Length', str(file_size))
|
||||||
|
response.headers.add('Cache-Control', 'no-cache')
|
||||||
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error serving audio file: {e}")
|
print(f"❌ Error serving audio file: {e}")
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,19 @@
|
||||||
<!-- Expanded Content (hidden initially) -->
|
<!-- Expanded Content (hidden initially) -->
|
||||||
<div class="media-expanded hidden" id="media-expanded">
|
<div class="media-expanded hidden" id="media-expanded">
|
||||||
<div class="album-name" id="album-name">Unknown Album</div>
|
<div class="album-name" id="album-name">Unknown Album</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar and Time Display -->
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="time-display">
|
||||||
|
<span class="current-time" id="current-time">0:00</span>
|
||||||
|
<span class="time-separator">/</span>
|
||||||
|
<span class="total-time" id="total-time">0:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<input type="range" class="progress-bar" id="progress-bar" min="0" max="100" value="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="media-controls">
|
<div class="media-controls">
|
||||||
<div class="volume-control">
|
<div class="volume-control">
|
||||||
<span class="volume-icon">🔊</span>
|
<span class="volume-icon">🔊</span>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ let currentStream = {
|
||||||
track: null
|
track: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Streaming state management (new functionality)
|
// Streaming state management (enhanced functionality)
|
||||||
let streamStatusPoller = null;
|
let streamStatusPoller = null;
|
||||||
let audioPlayer = null;
|
let audioPlayer = null;
|
||||||
|
let streamPollingRetries = 0;
|
||||||
|
let streamPollingInterval = 1000; // Start with 1-second polling
|
||||||
|
const maxStreamPollingRetries = 10;
|
||||||
let allSearchResults = [];
|
let allSearchResults = [];
|
||||||
let currentFilterType = 'all';
|
let currentFilterType = 'all';
|
||||||
let currentFilterFormat = 'all';
|
let currentFilterFormat = 'all';
|
||||||
|
|
@ -374,6 +377,19 @@ function initializeMediaPlayer() {
|
||||||
stopButton.addEventListener('click', handleStop);
|
stopButton.addEventListener('click', handleStop);
|
||||||
volumeSlider.addEventListener('input', handleVolumeChange);
|
volumeSlider.addEventListener('input', handleVolumeChange);
|
||||||
|
|
||||||
|
// Progress bar controls
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
// Handle seeking
|
||||||
|
progressBar.addEventListener('input', handleProgressBarChange);
|
||||||
|
progressBar.addEventListener('mousedown', () => {
|
||||||
|
progressBar.dataset.seeking = 'true';
|
||||||
|
});
|
||||||
|
progressBar.addEventListener('mouseup', () => {
|
||||||
|
delete progressBar.dataset.seeking;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update volume slider styling
|
// Update volume slider styling
|
||||||
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
|
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
|
||||||
}
|
}
|
||||||
|
|
@ -490,6 +506,19 @@ function clearTrack() {
|
||||||
document.getElementById('play-button').disabled = true;
|
document.getElementById('play-button').disabled = true;
|
||||||
document.getElementById('stop-button').disabled = true;
|
document.getElementById('stop-button').disabled = true;
|
||||||
|
|
||||||
|
// Reset progress bar and time displays
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.value = 0;
|
||||||
|
progressBar.style.setProperty('--progress-percent', '0%');
|
||||||
|
delete progressBar.dataset.seeking;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTimeElement = document.getElementById('current-time');
|
||||||
|
const totalTimeElement = document.getElementById('total-time');
|
||||||
|
if (currentTimeElement) currentTimeElement.textContent = '0:00';
|
||||||
|
if (totalTimeElement) totalTimeElement.textContent = '0:00';
|
||||||
|
|
||||||
// Hide loading animation
|
// Hide loading animation
|
||||||
hideLoadingAnimation();
|
hideLoadingAnimation();
|
||||||
|
|
||||||
|
|
@ -541,6 +570,35 @@ function handleVolumeChange(event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleProgressBarChange(event) {
|
||||||
|
// Handle seeking in the audio track
|
||||||
|
if (!audioPlayer || !audioPlayer.duration) return;
|
||||||
|
|
||||||
|
const progress = event.target.value;
|
||||||
|
const newTime = (progress / 100) * audioPlayer.duration;
|
||||||
|
|
||||||
|
console.log(`🎯 Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioPlayer.currentTime = newTime;
|
||||||
|
|
||||||
|
// Update visual progress immediately
|
||||||
|
event.target.style.setProperty('--progress-percent', `${progress}%`);
|
||||||
|
|
||||||
|
// Update time displays immediately
|
||||||
|
const currentTimeElement = document.getElementById('current-time');
|
||||||
|
if (currentTimeElement) {
|
||||||
|
currentTimeElement.textContent = formatTime(newTime);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Seek failed:', error.message);
|
||||||
|
// Reset progress bar to current position
|
||||||
|
const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
||||||
|
event.target.value = actualProgress;
|
||||||
|
event.target.style.setProperty('--progress-percent', `${actualProgress}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateVolumeSliderAppearance() {
|
function updateVolumeSliderAppearance() {
|
||||||
const slider = document.getElementById('volume-slider');
|
const slider = document.getElementById('volume-slider');
|
||||||
const value = slider.value;
|
const value = slider.value;
|
||||||
|
|
@ -638,14 +696,18 @@ async function startStream(searchResult) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStreamStatusPolling() {
|
function startStreamStatusPolling() {
|
||||||
// Start polling for stream status updates
|
// Start polling for stream status updates with retry logic
|
||||||
if (streamStatusPoller) {
|
if (streamStatusPoller) {
|
||||||
clearInterval(streamStatusPoller);
|
clearInterval(streamStatusPoller);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 Starting stream status polling (1-second interval)');
|
// Reset polling state
|
||||||
|
streamPollingRetries = 0;
|
||||||
|
streamPollingInterval = 1000; // Reset to 1-second interval
|
||||||
|
|
||||||
|
console.log('🔄 Starting enhanced stream status polling');
|
||||||
updateStreamStatus(); // Initial check
|
updateStreamStatus(); // Initial check
|
||||||
streamStatusPoller = setInterval(updateStreamStatus, 1000);
|
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopStreamStatusPolling() {
|
function stopStreamStatusPolling() {
|
||||||
|
|
@ -653,20 +715,34 @@ function stopStreamStatusPolling() {
|
||||||
if (streamStatusPoller) {
|
if (streamStatusPoller) {
|
||||||
clearInterval(streamStatusPoller);
|
clearInterval(streamStatusPoller);
|
||||||
streamStatusPoller = null;
|
streamStatusPoller = null;
|
||||||
|
streamPollingRetries = 0;
|
||||||
|
streamPollingInterval = 1000; // Reset interval
|
||||||
console.log('⏹️ Stopped stream status polling');
|
console.log('⏹️ Stopped stream status polling');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStreamStatus() {
|
async function updateStreamStatus() {
|
||||||
// Poll server for streaming progress and handle state changes
|
// Poll server for streaming progress and handle state changes with enhanced error recovery
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API.stream.status);
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout
|
||||||
|
|
||||||
|
const response = await fetch(API.stream.status, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Reset retry count on successful response
|
||||||
|
streamPollingRetries = 0;
|
||||||
|
streamPollingInterval = 1000; // Reset to normal interval
|
||||||
|
|
||||||
// Update current stream state
|
// Update current stream state
|
||||||
currentStream.status = data.status;
|
currentStream.status = data.status;
|
||||||
currentStream.progress = data.progress;
|
currentStream.progress = data.progress;
|
||||||
|
|
@ -674,14 +750,20 @@ async function updateStreamStatus() {
|
||||||
switch (data.status) {
|
switch (data.status) {
|
||||||
case 'loading':
|
case 'loading':
|
||||||
setLoadingProgress(data.progress);
|
setLoadingProgress(data.progress);
|
||||||
|
// Update loading text with progress
|
||||||
|
const loadingText = document.querySelector('.loading-text');
|
||||||
|
if (loadingText && data.progress > 0) {
|
||||||
|
loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'queued':
|
case 'queued':
|
||||||
// Show queue status
|
// Show queue status with better messaging
|
||||||
const loadingText = document.querySelector('.loading-text');
|
const queueText = document.querySelector('.loading-text');
|
||||||
if (loadingText) {
|
if (queueText) {
|
||||||
loadingText.textContent = 'Queued...';
|
queueText.textContent = 'Queuing with uploader...';
|
||||||
}
|
}
|
||||||
|
setLoadingProgress(0); // Reset progress for queue state
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ready':
|
case 'ready':
|
||||||
|
|
@ -695,32 +777,102 @@ async function updateStreamStatus() {
|
||||||
console.error('❌ Streaming error:', data.error_message);
|
console.error('❌ Streaming error:', data.error_message);
|
||||||
stopStreamStatusPolling();
|
stopStreamStatusPolling();
|
||||||
hideLoadingAnimation();
|
hideLoadingAnimation();
|
||||||
showToast(`Streaming error: ${data.error_message}`, 'error');
|
showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error');
|
||||||
|
clearTrack();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stopped':
|
||||||
|
// Handle stopped state
|
||||||
|
console.log('🛑 Stream stopped');
|
||||||
|
stopStreamStatusPolling();
|
||||||
|
hideLoadingAnimation();
|
||||||
clearTrack();
|
clearTrack();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating stream status:', error);
|
streamPollingRetries++;
|
||||||
// Don't clear everything on network errors - might be temporary
|
console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message);
|
||||||
|
|
||||||
|
if (streamPollingRetries >= maxStreamPollingRetries) {
|
||||||
|
// Too many consecutive failures - give up
|
||||||
|
console.error('❌ Stream status polling failed after maximum retries');
|
||||||
|
stopStreamStatusPolling();
|
||||||
|
hideLoadingAnimation();
|
||||||
|
showToast('Lost connection to streaming server', 'error');
|
||||||
|
clearTrack();
|
||||||
|
} else {
|
||||||
|
// Implement exponential backoff for retries
|
||||||
|
const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff
|
||||||
|
streamPollingInterval = 1000 * backoffMultiplier;
|
||||||
|
|
||||||
|
// Restart polling with new interval
|
||||||
|
if (streamStatusPoller) {
|
||||||
|
clearInterval(streamStatusPoller);
|
||||||
|
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
|
||||||
|
console.log(`🔄 Retrying stream status polling with ${streamPollingInterval}ms interval`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startAudioPlayback() {
|
async function startAudioPlayback() {
|
||||||
// Start HTML5 audio playback of the streamed file
|
// Start HTML5 audio playback of the streamed file with enhanced state management
|
||||||
try {
|
try {
|
||||||
if (!audioPlayer) {
|
if (!audioPlayer) {
|
||||||
throw new Error('Audio player not initialized');
|
throw new Error('Audio player not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state while preparing audio
|
||||||
|
const loadingText = document.querySelector('.loading-text');
|
||||||
|
if (loadingText) {
|
||||||
|
loadingText.textContent = 'Preparing playback...';
|
||||||
|
}
|
||||||
|
|
||||||
// Set audio source with cache-busting timestamp
|
// Set audio source with cache-busting timestamp
|
||||||
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
|
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
|
||||||
audioPlayer.src = audioUrl;
|
|
||||||
|
|
||||||
console.log(`🎵 Loading audio from: ${audioUrl}`);
|
console.log(`🎵 Loading audio from: ${audioUrl}`);
|
||||||
|
|
||||||
// Wait a moment for the file to be fully ready
|
// Clear any existing source first
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
audioPlayer.pause();
|
||||||
|
audioPlayer.currentTime = 0;
|
||||||
|
audioPlayer.src = '';
|
||||||
|
|
||||||
|
// Set new source
|
||||||
|
audioPlayer.src = audioUrl;
|
||||||
|
audioPlayer.load(); // Force reload
|
||||||
|
|
||||||
|
// Wait for audio to be ready with promise-based approach
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('Audio loading timeout'));
|
||||||
|
}, 15000); // 15-second timeout
|
||||||
|
|
||||||
|
const onCanPlay = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
audioPlayer.removeEventListener('canplay', onCanPlay);
|
||||||
|
audioPlayer.removeEventListener('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (event) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
audioPlayer.removeEventListener('canplay', onCanPlay);
|
||||||
|
audioPlayer.removeEventListener('error', onError);
|
||||||
|
const error = event.target.error || new Error('Audio loading failed');
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
audioPlayer.addEventListener('canplay', onCanPlay);
|
||||||
|
audioPlayer.addEventListener('error', onError);
|
||||||
|
|
||||||
|
// If already ready, resolve immediately
|
||||||
|
if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA
|
||||||
|
onCanPlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Audio loaded and ready for playback');
|
||||||
|
|
||||||
// Try to start playback with retry logic
|
// Try to start playback with retry logic
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
@ -734,6 +886,30 @@ async function startAudioPlayback() {
|
||||||
// Update UI to playing state
|
// Update UI to playing state
|
||||||
hideLoadingAnimation();
|
hideLoadingAnimation();
|
||||||
setPlayingState(true);
|
setPlayingState(true);
|
||||||
|
|
||||||
|
// Show media player if hidden
|
||||||
|
const noTrackMessage = document.getElementById('no-track-message');
|
||||||
|
if (noTrackMessage) {
|
||||||
|
noTrackMessage.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure media player is expanded when playback starts
|
||||||
|
if (!mediaPlayerExpanded) {
|
||||||
|
toggleMediaPlayerExpansion();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update volume to current slider value
|
||||||
|
const volumeSlider = document.getElementById('volume-slider');
|
||||||
|
if (volumeSlider) {
|
||||||
|
audioPlayer.volume = volumeSlider.value / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable play/stop buttons
|
||||||
|
const playButton = document.getElementById('play-button');
|
||||||
|
const stopButton = document.getElementById('stop-button');
|
||||||
|
if (playButton) playButton.disabled = false;
|
||||||
|
if (stopButton) stopButton.disabled = false;
|
||||||
|
|
||||||
return; // Success!
|
return; // Success!
|
||||||
|
|
||||||
} catch (playError) {
|
} catch (playError) {
|
||||||
|
|
@ -744,8 +920,8 @@ async function startAudioPlayback() {
|
||||||
throw playError; // Re-throw after max retries
|
throw playError; // Re-throw after max retries
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait before retry
|
// Wait before retry with exponential backoff
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -761,9 +937,13 @@ async function startAudioPlayback() {
|
||||||
error.message.includes('MEDIA_ELEMENT_ERROR')) {
|
error.message.includes('MEDIA_ELEMENT_ERROR')) {
|
||||||
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
|
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
|
||||||
} else if (error.message.includes('network') || error.message.includes('fetch')) {
|
} else if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||||
userMessage = 'Network error - please try again';
|
userMessage = 'Network error - please check your connection';
|
||||||
} else if (error.message.includes('decode')) {
|
} else if (error.message.includes('decode')) {
|
||||||
userMessage = 'Audio file is corrupted or incompatible';
|
userMessage = 'Audio file is corrupted or incompatible';
|
||||||
|
} else if (error.message.includes('timeout')) {
|
||||||
|
userMessage = 'Audio loading timeout - file may be too large';
|
||||||
|
} else if (error.message.includes('AbortError')) {
|
||||||
|
userMessage = 'Playback was interrupted';
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(userMessage, 'error');
|
showToast(userMessage, 'error');
|
||||||
|
|
@ -839,9 +1019,16 @@ function updateAudioProgress() {
|
||||||
if (!audioPlayer || !audioPlayer.duration) return;
|
if (!audioPlayer || !audioPlayer.duration) return;
|
||||||
|
|
||||||
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
||||||
// TODO: Update progress bar in sidebar when implemented
|
|
||||||
|
|
||||||
// Update time display if elements exist
|
// Update progress bar
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
if (progressBar && !progressBar.dataset.seeking) {
|
||||||
|
progressBar.value = progress;
|
||||||
|
// Update CSS custom property for visual progress fill
|
||||||
|
progressBar.style.setProperty('--progress-percent', `${progress}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update time display
|
||||||
const currentTimeElement = document.getElementById('current-time');
|
const currentTimeElement = document.getElementById('current-time');
|
||||||
const totalTimeElement = document.getElementById('total-time');
|
const totalTimeElement = document.getElementById('total-time');
|
||||||
|
|
||||||
|
|
@ -857,6 +1044,19 @@ function onAudioEnded() {
|
||||||
// Handle audio playback completion
|
// Handle audio playback completion
|
||||||
console.log('🏁 Audio playback ended');
|
console.log('🏁 Audio playback ended');
|
||||||
setPlayingState(false);
|
setPlayingState(false);
|
||||||
|
|
||||||
|
// Reset progress to beginning
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.value = 0;
|
||||||
|
progressBar.style.setProperty('--progress-percent', '0%');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTimeElement = document.getElementById('current-time');
|
||||||
|
if (currentTimeElement) {
|
||||||
|
currentTimeElement.textContent = '0:00';
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Auto-advance to next track if queue exists
|
// TODO: Auto-advance to next track if queue exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,99 @@ body {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Progress Section Styling */
|
||||||
|
.progress-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: 'Spotify Circular', -apple-system, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time, .total-time {
|
||||||
|
min-width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-separator {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::-webkit-slider-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(to right, #1db954 0%, #1db954 var(--progress-percent, 0%), #4f4f4f var(--progress-percent, 0%), #4f4f4f 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #1db954;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::-webkit-slider-thumb:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::-webkit-slider-thumb:active {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox progress bar styling */
|
||||||
|
.progress-bar::-moz-range-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(to right, #1db954 0%, #1db954 var(--progress-percent, 0%), #4f4f4f var(--progress-percent, 0%), #4f4f4f 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #1db954;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar::-moz-range-thumb:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
|
||||||
.media-controls {
|
.media-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue