stream functionality

This commit is contained in:
Broque Thomas 2025-08-23 15:03:25 -07:00
parent e31abbda27
commit 886c2e84c8
4 changed files with 925 additions and 143 deletions

View file

@ -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():

View file

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

View file

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

View file

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