This commit is contained in:
Broque Thomas 2025-08-28 17:57:04 -07:00
parent 58c00831bc
commit c5617d874e
4 changed files with 535 additions and 74 deletions

View file

@ -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}")

View file

@ -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>

View file

@ -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
}

View file

@ -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;