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