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 ipaddress
|
||||||
import subprocess
|
import subprocess
|
||||||
import platform
|
import platform
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
|
import glob
|
||||||
|
from pathlib import Path
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from flask import Flask, render_template, request, jsonify, redirect
|
from flask import Flask, render_template, request, jsonify, redirect, send_file
|
||||||
|
|
||||||
# --- Core Application Imports ---
|
# --- Core Application Imports ---
|
||||||
# Import the same core clients and config manager used by the GUI app
|
# Import the same core clients and config manager used by the GUI app
|
||||||
|
|
@ -41,6 +46,221 @@ except Exception as e:
|
||||||
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
||||||
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = None
|
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 ---
|
# --- Refactored Logic from GUI Threads ---
|
||||||
# This logic is extracted from your QThread classes to be used directly by Flask.
|
# 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'])
|
@app.route('/api/stream/start', methods=['POST'])
|
||||||
def stream_start():
|
def stream_start():
|
||||||
# Placeholder: simulates starting a media stream
|
"""Start streaming a track in the background"""
|
||||||
|
global stream_background_task
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
print(f"Simulating stream start for: {data.get('filename')}")
|
if not data:
|
||||||
return jsonify({"success": True, "track": 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')
|
@app.route('/api/stream/status')
|
||||||
def stream_status():
|
def stream_status():
|
||||||
# Placeholder: simulates stream status
|
"""Get current streaming status and progress"""
|
||||||
return jsonify({"status": "playing", "progress": 50, "track": {"title": "Bohemian Rhapsody"}})
|
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'])
|
@app.route('/stream/audio')
|
||||||
def stream_toggle():
|
def stream_audio():
|
||||||
# Placeholder: simulates toggling play/pause
|
"""Serve the audio file from the Stream folder"""
|
||||||
return jsonify({"playing": False})
|
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'])
|
@app.route('/api/stream/stop', methods=['POST'])
|
||||||
def stream_stop():
|
def stream_stop():
|
||||||
# Placeholder: simulates stopping a stream
|
"""Stop streaming and clean up"""
|
||||||
return jsonify({"success": True})
|
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'])
|
@app.route('/api/version-info', methods=['GET'])
|
||||||
def get_version_info():
|
def get_version_info():
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,9 @@
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<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 -->
|
<!-- Version Info Modal -->
|
||||||
<div class="version-modal-overlay hidden" id="version-modal-overlay" onclick="closeVersionModal()">
|
<div class="version-modal-overlay hidden" id="version-modal-overlay" onclick="closeVersionModal()">
|
||||||
<div class="version-modal" onclick="event.stopPropagation()">
|
<div class="version-modal" onclick="event.stopPropagation()">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ let currentStream = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
track: null
|
track: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Streaming state management (new functionality)
|
||||||
|
let streamStatusPoller = null;
|
||||||
|
let audioPlayer = null;
|
||||||
let allSearchResults = [];
|
let allSearchResults = [];
|
||||||
let currentFilterType = 'all';
|
let currentFilterType = 'all';
|
||||||
let currentFilterFormat = 'all';
|
let currentFilterFormat = 'all';
|
||||||
|
|
@ -57,6 +61,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Load initial data
|
// Load initial data
|
||||||
loadInitialData();
|
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!');
|
console.log('SoulSync WebUI initialized successfully!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -182,6 +197,21 @@ function initializeMediaPlayer() {
|
||||||
const stopButton = document.getElementById('stop-button');
|
const stopButton = document.getElementById('stop-button');
|
||||||
const volumeSlider = document.getElementById('volume-slider');
|
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
|
// Track title click - toggle expansion
|
||||||
trackTitle.addEventListener('click', toggleMediaPlayerExpansion);
|
trackTitle.addEventListener('click', toggleMediaPlayerExpansion);
|
||||||
|
|
||||||
|
|
@ -192,9 +222,6 @@ function initializeMediaPlayer() {
|
||||||
|
|
||||||
// Update volume slider styling
|
// Update volume slider styling
|
||||||
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
|
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
|
||||||
|
|
||||||
// Start stream status polling if needed
|
|
||||||
setInterval(updateStreamStatus, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMediaPlayerExpansion() {
|
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) {
|
function setTrackInfo(track) {
|
||||||
currentTrack = 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('artist-name').textContent = track.artist || 'Unknown Artist';
|
||||||
document.getElementById('album-name').textContent = track.album || 'Unknown Album';
|
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
|
// Enable controls
|
||||||
document.getElementById('play-button').disabled = false;
|
document.getElementById('play-button').disabled = false;
|
||||||
document.getElementById('stop-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() {
|
function clearTrack() {
|
||||||
currentTrack = null;
|
currentTrack = null;
|
||||||
isPlaying = false;
|
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('artist-name').textContent = 'Unknown Artist';
|
||||||
document.getElementById('album-name').textContent = 'Unknown Album';
|
document.getElementById('album-name').textContent = 'Unknown Album';
|
||||||
document.getElementById('play-button').textContent = '▷';
|
document.getElementById('play-button').textContent = '▷';
|
||||||
|
|
@ -264,50 +353,24 @@ function setPlayingState(playing) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePlayPause() {
|
async function handlePlayPause() {
|
||||||
if (!currentTrack) return;
|
// Use new streaming system toggle function
|
||||||
|
togglePlayback();
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
try {
|
// Use new streaming system stop function
|
||||||
const response = await fetch(API.stream.stop, { method: 'POST' });
|
await stopStream();
|
||||||
const data = await response.json();
|
clearTrack();
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVolumeChange(event) {
|
function handleVolumeChange(event) {
|
||||||
const volume = event.target.value;
|
const volume = event.target.value;
|
||||||
updateVolumeSliderAppearance();
|
updateVolumeSliderAppearance();
|
||||||
|
|
||||||
// Send volume change to backend
|
// Update HTML5 audio player volume
|
||||||
fetch('/api/media/volume', {
|
if (audioPlayer) {
|
||||||
method: 'POST',
|
audioPlayer.volume = volume / 100;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
body: JSON.stringify({ volume: volume / 100 })
|
|
||||||
}).catch(error => console.error('Error setting volume:', error));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVolumeSliderAppearance() {
|
function updateVolumeSliderAppearance() {
|
||||||
|
|
@ -334,36 +397,293 @@ function setLoadingProgress(percentage) {
|
||||||
loadingText.textContent = `${Math.round(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 {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.track && currentStream.status !== data.status) {
|
if (!data.success) {
|
||||||
currentStream = data;
|
throw new Error(data.error || 'Failed to start streaming');
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("✅ Stream started successfully");
|
||||||
|
|
||||||
|
// Start status polling
|
||||||
|
startStreamStatusPolling();
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
// DONATION WIDGET
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -790,7 +1110,7 @@ function displaySearchResults(results) {
|
||||||
${result.album ? `<div class="result-album">${escapeHtml(result.album)}</div>` : ''}
|
${result.album ? `<div class="result-album">${escapeHtml(result.album)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="result-actions">
|
<div class="result-actions">
|
||||||
<button class="stream-button" onclick="event.stopPropagation(); startStream(${index})">
|
<button class="stream-button" onclick="event.stopPropagation(); streamTrack(${index})">
|
||||||
▷ Stream
|
▷ Stream
|
||||||
</button>
|
</button>
|
||||||
<button class="download-button" onclick="event.stopPropagation(); startDownload(${index})">
|
<button class="download-button" onclick="event.stopPropagation(); startDownload(${index})">
|
||||||
|
|
@ -816,50 +1136,6 @@ function selectResult(index) {
|
||||||
// Could show detailed view or additional actions here
|
// 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) {
|
async function startDownload(index) {
|
||||||
const result = searchResults[index];
|
const result = searchResults[index];
|
||||||
|
|
@ -1291,6 +1567,7 @@ function displayDownloadsResults(results) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-item-actions">
|
<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>
|
<button onclick="downloadAlbumTrack(${index}, ${trackIndex})" class="track-download-btn">⬇</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1335,6 +1612,7 @@ function displayDownloadsResults(results) {
|
||||||
<div class="track-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
|
<div class="track-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
|
<button onclick="streamTrack(${index})" class="track-stream-btn">▶ Stream</button>
|
||||||
<button onclick="downloadTrack(${index})" class="track-download-btn">⬇ Download</button>
|
<button onclick="downloadTrack(${index})" class="track-download-btn">⬇ Download</button>
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
async function loadArtistsData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API.artists);
|
const response = await fetch(API.artists);
|
||||||
|
|
@ -1797,6 +2135,8 @@ window.authenticateTidal = authenticateTidal;
|
||||||
window.browsePath = browsePath;
|
window.browsePath = browsePath;
|
||||||
window.selectResult = selectResult;
|
window.selectResult = selectResult;
|
||||||
window.startStream = startStream;
|
window.startStream = startStream;
|
||||||
|
window.streamTrack = streamTrack;
|
||||||
|
window.streamAlbumTrack = streamAlbumTrack;
|
||||||
window.startDownload = startDownload;
|
window.startDownload = startDownload;
|
||||||
window.downloadTrack = downloadTrack;
|
window.downloadTrack = downloadTrack;
|
||||||
window.downloadAlbum = downloadAlbum;
|
window.downloadAlbum = downloadAlbum;
|
||||||
|
|
|
||||||
|
|
@ -206,45 +206,80 @@ body {
|
||||||
|
|
||||||
.media-header {
|
.media-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-info {
|
.media-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-title {
|
.track-title {
|
||||||
font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, sans-serif;
|
font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.2px;
|
||||||
line-height: 1.2;
|
line-height: 1.3;
|
||||||
margin-bottom: 2px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
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 {
|
.track-title:hover {
|
||||||
color: #1ed760;
|
color: #1ed760;
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-name {
|
.artist-name {
|
||||||
font-family: 'Spotify Circular', -apple-system, sans-serif;
|
font-family: 'Spotify Circular', -apple-system, sans-serif;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
color: #b3b3b3;
|
color: #a7a7a7;
|
||||||
letter-spacing: 0.1px;
|
letter-spacing: 0.1px;
|
||||||
margin-top: 1px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
.play-button {
|
||||||
|
|
@ -280,26 +315,30 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-expanded {
|
.media-expanded {
|
||||||
margin-top: 2px;
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-name {
|
.album-name {
|
||||||
font-family: 'Spotify Circular', -apple-system, sans-serif;
|
font-family: 'Spotify Circular', -apple-system, sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
color: #a7a7a7;
|
color: #999999;
|
||||||
letter-spacing: 0.1px;
|
letter-spacing: 0.1px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-controls {
|
.media-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 6px;
|
margin-top: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
|
|
@ -378,14 +417,15 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-track-message {
|
.no-track-message {
|
||||||
color: #6a6a6a;
|
color: #888888;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 16px;
|
padding: 24px 20px;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.1px;
|
||||||
font-family: 'Spotify Circular', -apple-system, sans-serif;
|
font-family: 'Spotify Circular', -apple-system, sans-serif;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Crypto Donation Section - Matching CryptoDonationWidget */
|
/* Crypto Donation Section - Matching CryptoDonationWidget */
|
||||||
|
|
@ -2575,4 +2615,92 @@ body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 14px;
|
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 */
|
/* END OF CSS SNIPPET */
|
||||||
Loading…
Reference in a new issue