stream
This commit is contained in:
parent
58c00831bc
commit
c5617d874e
4 changed files with 535 additions and 74 deletions
255
web_server.py
255
web_server.py
|
|
@ -15,7 +15,7 @@ import re
|
|||
from pathlib import Path
|
||||
|
||||
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 ---
|
||||
# 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):
|
||||
"""
|
||||
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:
|
||||
print(f"🎵 Starting stream preparation for: {track_data.get('filename')}")
|
||||
|
||||
|
|
@ -498,7 +503,7 @@ def _prepare_stream_task(track_data):
|
|||
|
||||
# Get paths
|
||||
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')
|
||||
|
||||
# Ensure Stream directory exists
|
||||
|
|
@ -530,16 +535,26 @@ def _prepare_stream_task(track_data):
|
|||
with stream_lock:
|
||||
stream_state.update({
|
||||
"status": "error",
|
||||
"error_message": "Failed to initiate download"
|
||||
"error_message": "Failed to initiate download - uploader may be offline"
|
||||
})
|
||||
return
|
||||
|
||||
# Poll for completion with progress updates
|
||||
max_wait_time = 45 # Wait up to 45 seconds
|
||||
poll_interval = 2 # Check every 2 seconds
|
||||
print(f"✓ Download initiated for streaming")
|
||||
|
||||
for wait_count in range(max_wait_time // poll_interval):
|
||||
# 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
|
||||
|
||||
# Check download progress via slskd API
|
||||
api_progress = None
|
||||
download_state = None
|
||||
download_status = None
|
||||
|
||||
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)
|
||||
|
|
@ -549,80 +564,159 @@ def _prepare_stream_task(track_data):
|
|||
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:
|
||||
print(f"API Download - State: {original_state}, Progress: {api_progress:.1f}%")
|
||||
|
||||
# Track queue state timing (matching GUI logic)
|
||||
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"
|
||||
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"
|
||||
|
||||
# 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)
|
||||
# Check for queue timeout (matching GUI)
|
||||
if is_queued and queue_start_time:
|
||||
queue_elapsed = time.time() - queue_start_time
|
||||
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:
|
||||
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)
|
||||
|
||||
# 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:
|
||||
print(f"✓ Found downloaded file: {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
|
||||
try:
|
||||
shutil.move(found_file, stream_path)
|
||||
print(f"✓ Moved file to stream folder: {stream_path}")
|
||||
|
||||
# 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:
|
||||
stream_state.update({
|
||||
"status": "ready",
|
||||
"progress": 100,
|
||||
"file_path": stream_path
|
||||
"status": "error",
|
||||
"error_message": "Download completed but file not found"
|
||||
})
|
||||
|
||||
# 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
|
||||
|
||||
return
|
||||
else:
|
||||
# No transfer found in API - may still be initializing
|
||||
print(f"No transfer found in API yet... (elapsed: {wait_count * poll_interval}s)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error checking download progress: {e}")
|
||||
# Continue to next iteration if API call fails
|
||||
|
||||
# Wait before next poll
|
||||
time.sleep(poll_interval)
|
||||
|
||||
# If we get here, download timed out
|
||||
print(f"❌ Download timed out after {max_wait_time}s")
|
||||
with stream_lock:
|
||||
stream_state.update({
|
||||
"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:
|
||||
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:
|
||||
print(f"❌ Stream preparation failed: {e}")
|
||||
with stream_lock:
|
||||
stream_state.update({
|
||||
"status": "error",
|
||||
"error_message": str(e)
|
||||
"error_message": f"Streaming error: {str(e)}"
|
||||
})
|
||||
|
||||
def _find_streaming_download_in_transfers(transfers_data, track_data):
|
||||
|
|
@ -1500,7 +1594,7 @@ def stream_status():
|
|||
|
||||
@app.route('/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:
|
||||
with stream_lock:
|
||||
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
||||
|
|
@ -1525,9 +1619,70 @@ def stream_audio():
|
|||
'.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:
|
||||
print(f"❌ Error serving audio file: {e}")
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@
|
|||
<!-- Expanded Content (hidden initially) -->
|
||||
<div class="media-expanded hidden" id="media-expanded">
|
||||
<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="volume-control">
|
||||
<span class="volume-icon">🔊</span>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ let currentStream = {
|
|||
track: null
|
||||
};
|
||||
|
||||
// Streaming state management (new functionality)
|
||||
// Streaming state management (enhanced functionality)
|
||||
let streamStatusPoller = null;
|
||||
let audioPlayer = null;
|
||||
let streamPollingRetries = 0;
|
||||
let streamPollingInterval = 1000; // Start with 1-second polling
|
||||
const maxStreamPollingRetries = 10;
|
||||
let allSearchResults = [];
|
||||
let currentFilterType = 'all';
|
||||
let currentFilterFormat = 'all';
|
||||
|
|
@ -374,6 +377,19 @@ function initializeMediaPlayer() {
|
|||
stopButton.addEventListener('click', handleStop);
|
||||
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
|
||||
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
|
||||
}
|
||||
|
|
@ -490,6 +506,19 @@ function clearTrack() {
|
|||
document.getElementById('play-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
|
||||
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() {
|
||||
const slider = document.getElementById('volume-slider');
|
||||
const value = slider.value;
|
||||
|
|
@ -638,14 +696,18 @@ async function startStream(searchResult) {
|
|||
}
|
||||
|
||||
function startStreamStatusPolling() {
|
||||
// Start polling for stream status updates
|
||||
// Start polling for stream status updates with retry logic
|
||||
if (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
|
||||
streamStatusPoller = setInterval(updateStreamStatus, 1000);
|
||||
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
|
||||
}
|
||||
|
||||
function stopStreamStatusPolling() {
|
||||
|
|
@ -653,20 +715,34 @@ function stopStreamStatusPolling() {
|
|||
if (streamStatusPoller) {
|
||||
clearInterval(streamStatusPoller);
|
||||
streamStatusPoller = null;
|
||||
streamPollingRetries = 0;
|
||||
streamPollingInterval = 1000; // Reset interval
|
||||
console.log('⏹️ Stopped stream status polling');
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Reset retry count on successful response
|
||||
streamPollingRetries = 0;
|
||||
streamPollingInterval = 1000; // Reset to normal interval
|
||||
|
||||
// Update current stream state
|
||||
currentStream.status = data.status;
|
||||
currentStream.progress = data.progress;
|
||||
|
|
@ -674,14 +750,20 @@ async function updateStreamStatus() {
|
|||
switch (data.status) {
|
||||
case 'loading':
|
||||
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;
|
||||
|
||||
case 'queued':
|
||||
// Show queue status
|
||||
const loadingText = document.querySelector('.loading-text');
|
||||
if (loadingText) {
|
||||
loadingText.textContent = 'Queued...';
|
||||
// Show queue status with better messaging
|
||||
const queueText = document.querySelector('.loading-text');
|
||||
if (queueText) {
|
||||
queueText.textContent = 'Queuing with uploader...';
|
||||
}
|
||||
setLoadingProgress(0); // Reset progress for queue state
|
||||
break;
|
||||
|
||||
case 'ready':
|
||||
|
|
@ -695,32 +777,102 @@ async function updateStreamStatus() {
|
|||
console.error('❌ Streaming error:', data.error_message);
|
||||
stopStreamStatusPolling();
|
||||
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();
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating stream status:', error);
|
||||
// Don't clear everything on network errors - might be temporary
|
||||
streamPollingRetries++;
|
||||
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() {
|
||||
// Start HTML5 audio playback of the streamed file
|
||||
// Start HTML5 audio playback of the streamed file with enhanced state management
|
||||
try {
|
||||
if (!audioPlayer) {
|
||||
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
|
||||
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
|
||||
audioPlayer.src = audioUrl;
|
||||
|
||||
console.log(`🎵 Loading audio from: ${audioUrl}`);
|
||||
|
||||
// Wait a moment for the file to be fully ready
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Clear any existing source first
|
||||
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
|
||||
let retryCount = 0;
|
||||
|
|
@ -734,6 +886,30 @@ async function startAudioPlayback() {
|
|||
// Update UI to playing state
|
||||
hideLoadingAnimation();
|
||||
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!
|
||||
|
||||
} catch (playError) {
|
||||
|
|
@ -744,8 +920,8 @@ async function startAudioPlayback() {
|
|||
throw playError; // Re-throw after max retries
|
||||
}
|
||||
|
||||
// Wait before retry
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// Wait before retry with exponential backoff
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -761,9 +937,13 @@ async function startAudioPlayback() {
|
|||
error.message.includes('MEDIA_ELEMENT_ERROR')) {
|
||||
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
|
||||
} 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')) {
|
||||
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');
|
||||
|
|
@ -839,9 +1019,16 @@ function updateAudioProgress() {
|
|||
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
|
||||
// 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 totalTimeElement = document.getElementById('total-time');
|
||||
|
||||
|
|
@ -857,6 +1044,19 @@ function onAudioEnded() {
|
|||
// Handle audio playback completion
|
||||
console.log('🏁 Audio playback ended');
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -334,6 +334,99 @@ body {
|
|||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
Loading…
Reference in a new issue