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 subprocess
import platform
import threading
import time
import shutil
import glob
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect
from flask import Flask, render_template, request, jsonify, redirect, send_file
# --- Core Application Imports ---
# Import the same core clients and config manager used by the GUI app
@ -41,6 +46,221 @@ except Exception as e:
print(f"🔴 FATAL: Error initializing service clients: {e}")
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = None
# --- Global Streaming State Management ---
# Thread-safe state tracking for streaming functionality
stream_state = {
"status": "stopped", # States: stopped, loading, queued, ready, error
"progress": 0,
"track_info": None,
"file_path": None, # Path to the audio file in the 'Stream' folder
"error_message": None
}
stream_lock = threading.Lock() # Prevent race conditions
stream_background_task = None
stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time
def _prepare_stream_task(track_data):
"""
Background streaming task that downloads track to Stream folder and updates global state.
This replicates the logic from StreamingThread.run() in the GUI app.
"""
try:
print(f"🎵 Starting stream preparation for: {track_data.get('filename')}")
# Update state to loading
with stream_lock:
stream_state.update({
"status": "loading",
"progress": 0,
"track_info": track_data,
"file_path": None,
"error_message": None
})
# Get paths
download_path = config_manager.get('soulseek.download_path', './downloads')
project_root = os.path.dirname(os.path.abspath(__file__)) # Web server root
stream_folder = os.path.join(project_root, 'Stream')
# Ensure Stream directory exists
os.makedirs(stream_folder, exist_ok=True)
# Clear any existing files in Stream folder (only one file at a time)
for existing_file in glob.glob(os.path.join(stream_folder, '*')):
try:
if os.path.isfile(existing_file):
os.remove(existing_file)
elif os.path.isdir(existing_file):
shutil.rmtree(existing_file)
print(f"🗑️ Cleared old stream file: {existing_file}")
except Exception as e:
print(f"⚠️ Could not remove existing stream file: {e}")
# Start the download using the same mechanism as regular downloads
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
download_result = loop.run_until_complete(soulseek_client.download(
track_data.get('username'),
track_data.get('filename'),
track_data.get('size', 0)
))
if not download_result:
with stream_lock:
stream_state.update({
"status": "error",
"error_message": "Failed to initiate download"
})
return
# Poll for completion with progress updates
max_wait_time = 45 # Wait up to 45 seconds
poll_interval = 2 # Check every 2 seconds
for wait_count in range(max_wait_time // poll_interval):
# Check download progress via slskd API
try:
transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads'))
download_status = _find_streaming_download_in_transfers(transfers_data, track_data)
if download_status:
api_progress = download_status.get('percentComplete', 0)
download_state = download_status.get('state', '').lower()
original_state = download_status.get('state', '')
# Update progress
with stream_lock:
stream_state["progress"] = api_progress
if 'queued' in download_state or 'initializing' in download_state:
stream_state["status"] = "queued"
elif 'inprogress' in download_state:
stream_state["status"] = "loading"
# Check if download is complete
is_completed = ('Succeeded' in original_state or
('Completed' in original_state and 'Errored' not in original_state) or
api_progress >= 100)
if is_completed:
print(f"✓ Download completed via API status: {original_state}")
# Try to find the actual file
found_file = _find_downloaded_file(download_path, track_data)
if found_file:
# Move file to Stream folder
original_filename = os.path.basename(found_file)
stream_path = os.path.join(stream_folder, original_filename)
shutil.move(found_file, stream_path)
print(f"✓ Moved file to stream folder: {stream_path}")
# Update state to ready
with stream_lock:
stream_state.update({
"status": "ready",
"progress": 100,
"file_path": stream_path
})
# Clean up download from slskd API
try:
download_id = download_status.get('id', '')
if download_id:
success = loop.run_until_complete(
soulseek_client.signal_download_completion(
download_id, track_data.get('username'), remove=True)
)
if success:
print(f"✓ Cleaned up download {download_id} from API")
except Exception as e:
print(f"⚠️ Error cleaning up download: {e}")
return # Success!
else:
print("❌ Could not find downloaded file")
break
except Exception as e:
print(f"⚠️ Error checking download progress: {e}")
# Wait before next poll
time.sleep(poll_interval)
# If we get here, download timed out
with stream_lock:
stream_state.update({
"status": "error",
"error_message": "Download timed out"
})
finally:
loop.close()
except Exception as e:
print(f"❌ Stream preparation failed: {e}")
with stream_lock:
stream_state.update({
"status": "error",
"error_message": str(e)
})
def _find_streaming_download_in_transfers(transfers_data, track_data):
"""Find streaming download in transfer data using same logic as download queue"""
try:
if not transfers_data:
return None
# Flatten the transfers data structure
all_transfers = []
for user_data in transfers_data:
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
all_transfers.extend(directory['files'])
# Look for our specific file by filename and username
target_filename = os.path.basename(track_data.get('filename', ''))
target_username = track_data.get('username', '')
for transfer in all_transfers:
transfer_filename = os.path.basename(transfer.get('filename', ''))
transfer_username = transfer.get('username', '')
if (transfer_filename == target_filename and
transfer_username == target_username):
return transfer
return None
except Exception as e:
print(f"Error finding streaming download in transfers: {e}")
return None
def _find_downloaded_file(download_path, track_data):
"""Find the downloaded audio file in the downloads directory tree"""
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
target_filename = os.path.basename(track_data.get('filename', ''))
try:
# Walk through the downloads directory to find the file
for root, dirs, files in os.walk(download_path):
for file in files:
# Check if this is our target file
if file == target_filename:
file_path = os.path.join(root, file)
# Verify it's an audio file and has content
if (os.path.splitext(file)[1].lower() in audio_extensions and
os.path.getsize(file_path) > 1024): # At least 1KB
return file_path
print(f"❌ Could not find downloaded file: {target_filename}")
return None
except Exception as e:
print(f"Error searching for downloaded file: {e}")
return None
# --- Refactored Logic from GUI Threads ---
# This logic is extracted from your QThread classes to be used directly by Flask.
@ -678,25 +898,116 @@ def get_artists():
@app.route('/api/stream/start', methods=['POST'])
def stream_start():
# Placeholder: simulates starting a media stream
"""Start streaming a track in the background"""
global stream_background_task
data = request.get_json()
print(f"Simulating stream start for: {data.get('filename')}")
return jsonify({"success": True, "track": data})
if not data:
return jsonify({"success": False, "error": "No track data provided"}), 400
print(f"🎵 Web UI Stream request for: {data.get('filename')}")
try:
# Stop any existing streaming task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
# Reset stream state
with stream_lock:
stream_state.update({
"status": "stopped",
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None
})
# Start new background streaming task
stream_background_task = stream_executor.submit(_prepare_stream_task, data)
return jsonify({"success": True, "message": "Streaming started"})
except Exception as e:
print(f"❌ Error starting stream: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/stream/status')
def stream_status():
# Placeholder: simulates stream status
return jsonify({"status": "playing", "progress": 50, "track": {"title": "Bohemian Rhapsody"}})
"""Get current streaming status and progress"""
try:
with stream_lock:
# Return copy of current stream state
return jsonify({
"status": stream_state["status"],
"progress": stream_state["progress"],
"track_info": stream_state["track_info"],
"error_message": stream_state["error_message"]
})
except Exception as e:
print(f"❌ Error getting stream status: {e}")
return jsonify({
"status": "error",
"progress": 0,
"track_info": None,
"error_message": str(e)
}), 500
@app.route('/api/stream/toggle', methods=['POST'])
def stream_toggle():
# Placeholder: simulates toggling play/pause
return jsonify({"playing": False})
@app.route('/stream/audio')
def stream_audio():
"""Serve the audio file from the Stream folder"""
try:
with stream_lock:
if stream_state["status"] != "ready" or not stream_state["file_path"]:
return jsonify({"error": "No audio file ready for streaming"}), 404
file_path = stream_state["file_path"]
if not os.path.exists(file_path):
return jsonify({"error": "Audio file not found"}), 404
print(f"🎵 Serving audio file: {os.path.basename(file_path)}")
return send_file(file_path, as_attachment=False)
except Exception as e:
print(f"❌ Error serving audio file: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/stream/stop', methods=['POST'])
def stream_stop():
# Placeholder: simulates stopping a stream
return jsonify({"success": True})
"""Stop streaming and clean up"""
global stream_background_task
try:
# Cancel background task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
# Clear Stream folder
project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream')
if os.path.exists(stream_folder):
for filename in os.listdir(stream_folder):
file_path = os.path.join(stream_folder, filename)
if os.path.isfile(file_path):
os.remove(file_path)
print(f"🗑️ Removed stream file: {filename}")
# Reset stream state
with stream_lock:
stream_state.update({
"status": "stopped",
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None
})
return jsonify({"success": True, "message": "Stream stopped"})
except Exception as e:
print(f"❌ Error stopping stream: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/version-info', methods=['GET'])
def get_version_info():

View file

@ -583,6 +583,9 @@
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<!-- Hidden HTML5 Audio Player for Streaming -->
<audio id="audio-player" style="display: none;"></audio>
<!-- Version Info Modal -->
<div class="version-modal-overlay hidden" id="version-modal-overlay" onclick="closeVersionModal()">
<div class="version-modal" onclick="event.stopPropagation()">

View file

@ -12,6 +12,10 @@ let currentStream = {
progress: 0,
track: null
};
// Streaming state management (new functionality)
let streamStatusPoller = null;
let audioPlayer = null;
let allSearchResults = [];
let currentFilterType = 'all';
let currentFilterFormat = 'all';
@ -57,6 +61,17 @@ document.addEventListener('DOMContentLoaded', function() {
// Load initial data
loadInitialData();
// Handle window resize to re-check track title scrolling
window.addEventListener('resize', function() {
if (currentTrack) {
const trackTitleElement = document.getElementById('track-title');
const trackTitle = currentTrack.title || 'Unknown Track';
setTimeout(() => {
checkAndEnableScrolling(trackTitleElement, trackTitle);
}, 100); // Small delay to allow layout to settle
}
});
console.log('SoulSync WebUI initialized successfully!');
});
@ -182,6 +197,21 @@ function initializeMediaPlayer() {
const stopButton = document.getElementById('stop-button');
const volumeSlider = document.getElementById('volume-slider');
// Initialize HTML5 audio player
audioPlayer = document.getElementById('audio-player');
if (audioPlayer) {
// Set up audio event listeners
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
audioPlayer.addEventListener('ended', onAudioEnded);
audioPlayer.addEventListener('error', onAudioError);
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
audioPlayer.addEventListener('canplay', onAudioCanPlay);
// Set initial volume
audioPlayer.volume = 0.7; // 70%
volumeSlider.value = 70;
}
// Track title click - toggle expansion
trackTitle.addEventListener('click', toggleMediaPlayerExpansion);
@ -192,9 +222,6 @@ function initializeMediaPlayer() {
// Update volume slider styling
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
// Start stream status polling if needed
setInterval(updateStreamStatus, 1000);
}
function toggleMediaPlayerExpansion() {
@ -216,13 +243,44 @@ function toggleMediaPlayerExpansion() {
}
}
function extractTrackTitle(filename) {
if (!filename) return null;
// Remove file extension
let title = filename.replace(/\.[^/.]+$/, '');
// Remove path components, keep only the filename
title = title.split('/').pop().split('\\').pop();
// Clean up common filename patterns
title = title
.replace(/^\d+\.?\s*/, '') // Remove track numbers at start
.replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns
.replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end
.replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info
.replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end
.trim();
return title || null;
}
function setTrackInfo(track) {
currentTrack = track;
document.getElementById('track-title').textContent = track.title || 'Unknown Track';
const trackTitleElement = document.getElementById('track-title');
const trackTitle = track.title || 'Unknown Track';
// Set up the HTML structure for scrolling
trackTitleElement.innerHTML = `<span class="title-text">${escapeHtml(trackTitle)}</span>`;
document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist';
document.getElementById('album-name').textContent = track.album || 'Unknown Album';
// Check if title needs scrolling (similar to GUI app)
setTimeout(() => {
checkAndEnableScrolling(trackTitleElement, trackTitle);
}, 100); // Allow DOM to settle
// Enable controls
document.getElementById('play-button').disabled = false;
document.getElementById('stop-button').disabled = false;
@ -236,11 +294,42 @@ function setTrackInfo(track) {
}
}
function checkAndEnableScrolling(element, text) {
// Remove any existing scrolling class and reset styles
element.classList.remove('scrolling');
element.style.removeProperty('--scroll-distance');
// Force a layout to get accurate measurements
element.offsetWidth;
// Get the inner text element
const titleTextElement = element.querySelector('.title-text');
if (!titleTextElement) return;
// Check if text is wider than container
const containerWidth = element.offsetWidth;
const textWidth = titleTextElement.scrollWidth;
// Enable scrolling if text is significantly wider than container
if (textWidth > containerWidth + 15) {
const scrollDistance = containerWidth - textWidth;
element.style.setProperty('--scroll-distance', `${scrollDistance}px`);
element.classList.add('scrolling');
console.log(`📜 Enabled scrolling for title: "${text}"`);
console.log(`📜 Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`);
}
}
function clearTrack() {
currentTrack = null;
isPlaying = false;
document.getElementById('track-title').textContent = 'No track';
const trackTitleElement = document.getElementById('track-title');
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation
trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable
document.getElementById('artist-name').textContent = 'Unknown Artist';
document.getElementById('album-name').textContent = 'Unknown Album';
document.getElementById('play-button').textContent = '▷';
@ -264,50 +353,24 @@ function setPlayingState(playing) {
}
async function handlePlayPause() {
if (!currentTrack) return;
try {
const response = await fetch(API.stream.toggle, { method: 'POST' });
const data = await response.json();
if (data.error) {
showToast(`Playback error: ${data.error}`, 'error');
} else {
setPlayingState(data.playing);
}
} catch (error) {
console.error('Error toggling playback:', error);
showToast('Failed to toggle playback', 'error');
}
// Use new streaming system toggle function
togglePlayback();
}
async function handleStop() {
try {
const response = await fetch(API.stream.stop, { method: 'POST' });
const data = await response.json();
if (data.error) {
showToast(`Stop error: ${data.error}`, 'error');
} else {
clearTrack();
currentStream.status = 'stopped';
}
} catch (error) {
console.error('Error stopping playback:', error);
showToast('Failed to stop playback', 'error');
}
// Use new streaming system stop function
await stopStream();
clearTrack();
}
function handleVolumeChange(event) {
const volume = event.target.value;
updateVolumeSliderAppearance();
// Send volume change to backend
fetch('/api/media/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: volume / 100 })
}).catch(error => console.error('Error setting volume:', error));
// Update HTML5 audio player volume
if (audioPlayer) {
audioPlayer.volume = volume / 100;
}
}
function updateVolumeSliderAppearance() {
@ -334,36 +397,293 @@ function setLoadingProgress(percentage) {
loadingText.textContent = `${Math.round(percentage)}%`;
}
async function updateStreamStatus() {
// ===============================
// STREAMING FUNCTIONALITY
// ===============================
async function startStream(searchResult) {
// Start streaming a track - handles same track toggle and new track streaming
try {
const response = await fetch(API.stream.status);
console.log(`🎮 startStream() called with data:`, searchResult);
// Check if this is the same track that's currently playing/loading
const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null;
const newTrackId = `${searchResult.username}:${searchResult.filename}`;
console.log(`🎮 startStream() called for: ${searchResult.filename}`);
console.log(`🎮 Current track ID: ${currentTrackId}`);
console.log(`🎮 New track ID: ${newTrackId}`);
if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) {
// Same track clicked while playing - toggle pause
console.log("🔄 Toggling playback for same track");
togglePlayback();
return;
}
// Different track or no current track - start new stream
console.log("🎵 Starting new stream");
// Stop current streaming/playback if any
await stopStream();
// Set track info and show loading state
setTrackInfo({
title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track',
artist: searchResult.artist || searchResult.username || 'Unknown Artist',
album: searchResult.album || 'Unknown Album',
username: searchResult.username,
filename: searchResult.filename
});
showLoadingAnimation();
setLoadingProgress(0);
// Start streaming request
const response = await fetch(API.stream.start, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(searchResult)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.track && currentStream.status !== data.status) {
currentStream = data;
switch (data.status) {
case 'loading':
setLoadingProgress(data.progress);
break;
case 'playing':
hideLoadingAnimation();
setPlayingState(true);
break;
case 'paused':
hideLoadingAnimation();
setPlayingState(false);
break;
case 'stopped':
clearTrack();
break;
}
if (!data.success) {
throw new Error(data.error || 'Failed to start streaming');
}
console.log("✅ Stream started successfully");
// Start status polling
startStreamStatusPolling();
} catch (error) {
// Don't log errors for stream status - it's expected when no stream is active
console.error('Error starting stream:', error);
showToast(`Failed to start stream: ${error.message}`, 'error');
hideLoadingAnimation();
clearTrack();
}
}
function startStreamStatusPolling() {
// Start polling for stream status updates
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
}
console.log('🔄 Starting stream status polling (1-second interval)');
updateStreamStatus(); // Initial check
streamStatusPoller = setInterval(updateStreamStatus, 1000);
}
function stopStreamStatusPolling() {
// Stop polling for stream status updates
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
streamStatusPoller = null;
console.log('⏹️ Stopped stream status polling');
}
}
async function updateStreamStatus() {
// Poll server for streaming progress and handle state changes
try {
const response = await fetch(API.stream.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Update current stream state
currentStream.status = data.status;
currentStream.progress = data.progress;
switch (data.status) {
case 'loading':
setLoadingProgress(data.progress);
break;
case 'queued':
// Show queue status
const loadingText = document.querySelector('.loading-text');
if (loadingText) {
loadingText.textContent = 'Queued...';
}
break;
case 'ready':
// Stream is ready - start audio playback
console.log('🎵 Stream ready, starting audio playback');
stopStreamStatusPolling();
await startAudioPlayback();
break;
case 'error':
console.error('❌ Streaming error:', data.error_message);
stopStreamStatusPolling();
hideLoadingAnimation();
showToast(`Streaming error: ${data.error_message}`, 'error');
clearTrack();
break;
}
} catch (error) {
console.error('Error updating stream status:', error);
// Don't clear everything on network errors - might be temporary
}
}
async function startAudioPlayback() {
// Start HTML5 audio playback of the streamed file
try {
if (!audioPlayer) {
throw new Error('Audio player not initialized');
}
// Set audio source with cache-busting timestamp
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
audioPlayer.src = audioUrl;
console.log(`🎵 Loading audio from: ${audioUrl}`);
// Start playback
await audioPlayer.play();
// Update UI to playing state
hideLoadingAnimation();
setPlayingState(true);
console.log('✅ Audio playback started successfully');
} catch (error) {
console.error('❌ Error starting audio playback:', error);
hideLoadingAnimation();
showToast(`Playback error: ${error.message}`, 'error');
clearTrack();
}
}
async function stopStream() {
// Stop streaming and clean up all state
try {
// Stop status polling
stopStreamStatusPolling();
// Stop audio playback
if (audioPlayer) {
audioPlayer.pause();
audioPlayer.src = '';
}
// Call backend stop endpoint
const response = await fetch(API.stream.stop, { method: 'POST' });
if (response.ok) {
const data = await response.json();
console.log('🛑 Stream stopped:', data.message);
}
// Reset UI state
hideLoadingAnimation();
setPlayingState(false);
// Reset stream state
currentStream = {
status: 'stopped',
progress: 0,
track: null
};
} catch (error) {
console.error('Error stopping stream:', error);
}
}
function togglePlayback() {
// Toggle play/pause for currently loaded audio
if (!audioPlayer || !currentTrack) {
console.log('⚠️ No audio player or track to toggle');
return;
}
if (audioPlayer.paused) {
audioPlayer.play()
.then(() => {
setPlayingState(true);
console.log('▶️ Resumed playback');
})
.catch(error => {
console.error('Error resuming playback:', error);
showToast('Failed to resume playback', 'error');
});
} else {
audioPlayer.pause();
setPlayingState(false);
console.log('⏸️ Paused playback');
}
}
// ===============================
// AUDIO EVENT HANDLERS
// ===============================
function updateAudioProgress() {
// Update progress bar based on audio playback time
if (!audioPlayer || !audioPlayer.duration) return;
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
// TODO: Update progress bar in sidebar when implemented
// Update time display if elements exist
const currentTimeElement = document.getElementById('current-time');
const totalTimeElement = document.getElementById('total-time');
if (currentTimeElement) {
currentTimeElement.textContent = formatTime(audioPlayer.currentTime);
}
if (totalTimeElement) {
totalTimeElement.textContent = formatTime(audioPlayer.duration);
}
}
function onAudioEnded() {
// Handle audio playback completion
console.log('🏁 Audio playback ended');
setPlayingState(false);
// TODO: Auto-advance to next track if queue exists
}
function onAudioError(event) {
// Handle audio playback errors
console.error('❌ Audio error:', event.target.error);
hideLoadingAnimation();
showToast('Audio playback error occurred', 'error');
clearTrack();
}
function onAudioLoadStart() {
// Handle audio load start
console.log('🔄 Audio loading started');
}
function onAudioCanPlay() {
// Handle when audio can start playing
console.log('✅ Audio ready to play');
}
function formatTime(seconds) {
// Format seconds as MM:SS
if (!seconds || !isFinite(seconds)) return '0:00';
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
// ===============================
// DONATION WIDGET
// ===============================
@ -790,7 +1110,7 @@ function displaySearchResults(results) {
${result.album ? `<div class="result-album">${escapeHtml(result.album)}</div>` : ''}
</div>
<div class="result-actions">
<button class="stream-button" onclick="event.stopPropagation(); startStream(${index})">
<button class="stream-button" onclick="event.stopPropagation(); streamTrack(${index})">
Stream
</button>
<button class="download-button" onclick="event.stopPropagation(); startDownload(${index})">
@ -816,50 +1136,6 @@ function selectResult(index) {
// Could show detailed view or additional actions here
}
async function startStream(index) {
const result = searchResults[index];
if (!result || result.type === 'album') {
showToast('Cannot stream albums (yet)', 'error');
return;
}
try {
showLoadingAnimation();
const streamData = {
username: result.username,
filename: result.filename,
title: result.title,
artist: result.artist,
album: result.album,
quality: result.quality,
bitrate: result.bitrate,
duration: result.duration,
size_mb: result.file_size / 1024 / 1024
};
const response = await fetch(API.stream.start, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(streamData)
});
const data = await response.json();
if (data.error) {
hideLoadingAnimation();
showToast(`Stream error: ${data.error}`, 'error');
} else {
setTrackInfo(data.track);
currentStream.status = 'loading';
showToast('Starting stream...', 'success');
}
} catch (error) {
hideLoadingAnimation();
console.error('Error starting stream:', error);
showToast('Failed to start stream', 'error');
}
}
async function startDownload(index) {
const result = searchResults[index];
@ -1291,6 +1567,7 @@ function displayDownloadsResults(results) {
</div>
</div>
<div class="track-item-actions">
<button onclick="streamAlbumTrack(${index}, ${trackIndex})" class="track-stream-btn"></button>
<button onclick="downloadAlbumTrack(${index}, ${trackIndex})" class="track-download-btn"></button>
</div>
</div>
@ -1335,6 +1612,7 @@ function displayDownloadsResults(results) {
<div class="track-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
</div>
<div class="track-actions">
<button onclick="streamTrack(${index})" class="track-stream-btn"> Stream</button>
<button onclick="downloadTrack(${index})" class="track-download-btn"> Download</button>
</div>
</div>
@ -1448,6 +1726,66 @@ async function downloadAlbumTrack(albumIndex, trackIndex) {
}
}
// ===============================
// STREAMING WRAPPER FUNCTIONS
// ===============================
async function streamTrack(index) {
// Stream a single track from search results
try {
console.log(`🎵 streamTrack called with index: ${index}`);
console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[index]) {
console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Track not found', 'error');
return;
}
const result = window.currentSearchResults[index];
console.log(`🎵 Streaming track:`, result);
await startStream(result);
} catch (error) {
console.error('Track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function streamAlbumTrack(albumIndex, trackIndex) {
// Stream a specific track from an album
try {
console.log(`🎵 streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`);
console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) {
console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Album not found', 'error');
return;
}
const album = window.currentSearchResults[albumIndex];
console.log(`🎵 Album data:`, album);
if (!album.tracks || !album.tracks[trackIndex]) {
console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`);
showToast('Track not found in album', 'error');
return;
}
const track = album.tracks[trackIndex];
console.log(`🎵 Streaming album track:`, track);
await startStream(track);
} catch (error) {
console.error('Album track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function loadArtistsData() {
try {
const response = await fetch(API.artists);
@ -1797,6 +2135,8 @@ window.authenticateTidal = authenticateTidal;
window.browsePath = browsePath;
window.selectResult = selectResult;
window.startStream = startStream;
window.streamTrack = streamTrack;
window.streamAlbumTrack = streamAlbumTrack;
window.startDownload = startDownload;
window.downloadTrack = downloadTrack;
window.downloadAlbum = downloadAlbum;

View file

@ -206,45 +206,80 @@ body {
.media-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 8px;
align-items: flex-start;
gap: 16px;
margin-bottom: 10px;
}
.media-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 2px;
}
.track-title {
font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, sans-serif;
font-size: 14px;
font-size: 15px;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.3px;
line-height: 1.2;
margin-bottom: 2px;
letter-spacing: -0.2px;
line-height: 1.3;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
transition: color 0.2s ease;
position: relative;
width: 100%;
}
.track-title .title-text {
display: inline-block;
transition: transform 0.3s ease;
}
.track-title.scrolling .title-text {
animation: marquee-scroll 10s linear infinite;
animation-delay: 1.5s;
}
.track-title.scrolling:hover .title-text {
animation-play-state: paused;
}
@keyframes marquee-scroll {
0% {
transform: translateX(0px);
}
20% {
transform: translateX(0px);
}
80% {
transform: translateX(var(--scroll-distance));
}
100% {
transform: translateX(var(--scroll-distance));
}
}
.track-title:hover {
color: #1ed760;
text-decoration: underline;
}
.artist-name {
font-family: 'Spotify Circular', -apple-system, sans-serif;
font-size: 11px;
font-weight: 400;
color: #b3b3b3;
font-size: 12px;
font-weight: 500;
color: #a7a7a7;
letter-spacing: 0.1px;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
opacity: 0.9;
}
.play-button {
@ -280,26 +315,30 @@ body {
}
.media-expanded {
margin-top: 2px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.album-name {
font-family: 'Spotify Circular', -apple-system, sans-serif;
font-size: 11px;
font-weight: 400;
color: #a7a7a7;
font-weight: 500;
color: #999999;
letter-spacing: 0.1px;
margin-bottom: 4px;
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
opacity: 0.8;
}
.media-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
margin-top: 0px;
}
.volume-control {
@ -378,14 +417,15 @@ body {
}
.no-track-message {
color: #6a6a6a;
font-size: 12px;
font-weight: 400;
color: #888888;
font-size: 13px;
font-weight: 500;
text-align: center;
padding: 20px 16px;
letter-spacing: 0.2px;
padding: 24px 20px;
letter-spacing: 0.1px;
font-family: 'Spotify Circular', -apple-system, sans-serif;
line-height: 1.4;
line-height: 1.5;
opacity: 0.85;
}
/* Crypto Donation Section - Matching CryptoDonationWidget */
@ -2575,4 +2615,92 @@ body {
padding: 0;
font-size: 14px;
}
/* ===============================
STREAMING BUTTON STYLES
=============================== */
/* Track stream buttons */
.track-stream-btn {
background: linear-gradient(to bottom, rgba(29, 185, 84, 0.1), rgba(25, 155, 71, 0.2));
border: 1px solid rgba(29, 185, 84, 0.4);
color: rgba(29, 185, 84, 0.9);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
}
.track-stream-btn:hover {
background: linear-gradient(to bottom, rgba(29, 185, 84, 0.2), rgba(25, 155, 71, 0.3));
border-color: rgba(29, 185, 84, 0.6);
color: rgba(29, 185, 84, 1);
transform: scale(1.05);
}
/* Album stream buttons */
.album-stream-btn {
background: linear-gradient(to bottom, rgba(29, 185, 84, 0.1), rgba(25, 155, 71, 0.2));
border: 1px solid rgba(29, 185, 84, 0.4);
color: rgba(29, 185, 84, 0.9);
border-radius: 8px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
}
.album-stream-btn:hover {
background: linear-gradient(to bottom, rgba(29, 185, 84, 0.2), rgba(25, 155, 71, 0.3));
border-color: rgba(29, 185, 84, 0.6);
color: rgba(29, 185, 84, 1);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2);
}
/* Loading states for stream buttons */
.track-stream-btn:disabled,
.album-stream-btn:disabled {
background: linear-gradient(to bottom, rgba(120, 120, 120, 0.2), rgba(100, 100, 100, 0.3));
border-color: rgba(120, 120, 120, 0.3);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
transform: none;
}
/* Active/playing states */
.track-stream-btn.playing,
.album-stream-btn.playing {
background: linear-gradient(to bottom, rgba(29, 185, 84, 0.3), rgba(25, 155, 71, 0.4));
border-color: rgba(29, 185, 84, 0.8);
color: #ffffff;
box-shadow: 0 0 8px rgba(29, 185, 84, 0.4);
}
.track-stream-btn.loading,
.album-stream-btn.loading {
background: linear-gradient(to bottom, rgba(255, 193, 7, 0.2), rgba(255, 152, 0, 0.3));
border-color: rgba(255, 193, 7, 0.5);
color: rgba(255, 193, 7, 0.9);
animation: pulse-loading 1.5s infinite;
}
@keyframes pulse-loading {
0% { opacity: 0.7; }
50% { opacity: 1; }
100% { opacity: 0.7; }
}
/* END OF CSS SNIPPET */