// MEDIA PLAYER FUNCTIONALITY // =============================== function initializeMediaPlayer() { const trackTitle = document.getElementById('track-title'); const playButton = document.getElementById('play-button'); const stopButton = document.getElementById('stop-button'); const volumeSlider = document.getElementById('volume-slider'); // Start in idle state (no track playing) const player = document.getElementById('media-player'); if (player && !currentTrack) player.classList.add('idle'); // Initialize HTML5 audio player audioPlayer = document.getElementById('audio-player'); if (audioPlayer) { // Set up audio event listeners audioPlayer.addEventListener('timeupdate', updateAudioProgress); audioPlayer.addEventListener('timeupdate', npCrossfadeTick); audioPlayer.addEventListener('timeupdate', npThrottledPositionState); audioPlayer.addEventListener('timeupdate', npMaybeLogPlay); audioPlayer.addEventListener('ended', onAudioEnded); audioPlayer.addEventListener('error', onAudioError); audioPlayer.addEventListener('loadstart', onAudioLoadStart); audioPlayer.addEventListener('canplay', onAudioCanPlay); // Universal: once the visualizer routes this element through // npAudioContext, the element is silent while that context is // suspended. The 'play' event fires on EVERY playback start (every // code path, incl. ones that bypass setPlayingState), so resume here. audioPlayer.addEventListener('play', npEnsureAudioContextRunning); // Set initial volume — restore the saved level (Spotify-style), else 70%. const _savedVol = npLoadSavedVolume(); const _initialVol = _savedVol === null ? 70 : _savedVol; audioPlayer.volume = _initialVol / 100; if (volumeSlider) volumeSlider.value = _initialVol; // Sync the modal slider/fill too once DOM is ready. syncVolumeUI(_initialVol); } // Track title click handled by initExpandedPlayer's media-player click handler // Media controls playButton.addEventListener('click', handlePlayPause); stopButton.addEventListener('click', handleStop); if (volumeSlider) volumeSlider.addEventListener('input', handleVolumeChange); // Progress bar controls const progressBar = document.getElementById('progress-bar'); if (progressBar) { // Handle seeking progressBar.addEventListener('input', handleProgressBarChange); progressBar.addEventListener('mousedown', () => { progressBar.dataset.seeking = 'true'; }); progressBar.addEventListener('mouseup', () => { delete progressBar.dataset.seeking; }); } // Update volume slider styling if (volumeSlider) volumeSlider.addEventListener('input', updateVolumeSliderAppearance); // Mini player prev / next buttons const miniPrevBtn = document.getElementById('mini-prev-btn'); const miniNextBtn = document.getElementById('mini-next-btn'); if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); }); if (miniNextBtn) miniNextBtn.addEventListener('click', (e) => { e.stopPropagation(); playNextInQueue(); }); // Mini shuffle / repeat — share the modal handlers (which now sync both UIs) const miniShuffleBtn = document.getElementById('mini-shuffle-btn'); const miniRepeatBtn = document.getElementById('mini-repeat-btn'); if (miniShuffleBtn) miniShuffleBtn.addEventListener('click', (e) => { e.stopPropagation(); handleNpShuffle(); }); if (miniRepeatBtn) miniRepeatBtn.addEventListener('click', (e) => { e.stopPropagation(); handleNpRepeat(); }); // Restore a previously-saved queue (does not auto-play) npRestoreQueue(); } function toggleMediaPlayerExpansion() { // No-op: controls are always visible in the new layout. // Kept for backward compatibility with any callers. } function extractTrackTitle(filename) { if (!filename) return null; // Strip the ``||`` prefix used by YouTube / // Tidal / Qobuz / torrent / usenet plugins to thread the source- // side identifier through ``filename`` without polluting the // display string. The id always comes first, the human title // after. If no separator is present, fall through with the raw // value so existing slskd / streaming-source paths are untouched. let title = filename; const sepIdx = title.indexOf('||'); if (sepIdx >= 0) { title = title.slice(sepIdx + 2); } // Remove file extension title = title.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 _stripSourceIdPrefix(value) { // Defensive cleanup for callers that pass a raw ``||`` // string straight into setTrackInfo without first running // extractTrackTitle. The id always precedes the separator; the display // string follows. Strings with no separator pass through unchanged. if (!value || typeof value !== 'string') return value; const idx = value.indexOf('||'); if (idx < 0) return value; return value.slice(idx + 2); } function setTrackInfo(track) { currentTrack = track; npPlayLogged = false; // new track — allow one play-log once it's heard a bit const trackTitleElement = document.getElementById('track-title'); const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track'; // Set up the HTML structure for scrolling trackTitleElement.innerHTML = `${escapeHtml(trackTitle)}`; document.getElementById('artist-name').textContent = _stripSourceIdPrefix(track.artist) || 'Unknown Artist'; document.getElementById('album-name').textContent = _stripSourceIdPrefix(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; // Hide no track message and expand player document.getElementById('no-track-message').classList.add('hidden'); document.getElementById('media-player').classList.remove('idle'); const gotoArtistBtn = document.getElementById('np-goto-artist'); if (gotoArtistBtn) { if (track.artist_id) { gotoArtistBtn.href = buildArtistDetailPath(track.artist_id, track.artist_source || null); gotoArtistBtn.style.pointerEvents = ''; gotoArtistBtn.setAttribute('aria-disabled', 'false'); gotoArtistBtn.tabIndex = 0; } else { gotoArtistBtn.href = '#'; gotoArtistBtn.style.pointerEvents = 'none'; gotoArtistBtn.setAttribute('aria-disabled', 'true'); gotoArtistBtn.tabIndex = -1; } // Close the expanded now-playing modal when the user navigates // to the artist page — otherwise the modal sits open over the // page they just opened. ``_npGotoArtistHandlerAttached`` flag // keeps us from binding multiple listeners across setTrackInfo // calls (fires on every track change). if (!gotoArtistBtn._npGotoArtistHandlerAttached) { gotoArtistBtn.addEventListener('click', () => { if (gotoArtistBtn.getAttribute('aria-disabled') === 'true') return; try { closeNowPlayingModal(); } catch (e) { console.debug('closeNowPlayingModal failed:', e); } }); gotoArtistBtn._npGotoArtistHandlerAttached = true; } } // Sync expanded player and media session updateNpTrackInfo(); updateMediaSessionMetadata(); updateMediaSessionPlaybackState(); // Reset the lock-screen scrubber when duration becomes known for the new track. if (audioPlayer) audioPlayer.addEventListener('loadedmetadata', updateMediaSessionPositionState, { once: true }); // Kick off lyrics fetch for the new track. The panel stays // collapsed by default — fetching in the background means the // user gets instant lyrics the first time they expand it. _npLyricsLoadForTrack({ title: track.title, artist: track.artist, album: track.album, is_library: track.is_library, filename: track.filename, }); } 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() { // Clear track state currentTrack = null; isPlaying = false; npSetPlayContext(''); // hide "Playing from" when nothing's playing const trackTitleElement = document.getElementById('track-title'); trackTitleElement.innerHTML = 'No track'; 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'; // Reset play button SVGs (don't use textContent — it destroys SVG children) const clearPlayBtn = document.getElementById('play-button'); const clearPlayIcon = clearPlayBtn.querySelector('.play-icon'); const clearPauseIcon = clearPlayBtn.querySelector('.pause-icon'); if (clearPlayIcon) clearPlayIcon.style.display = ''; if (clearPauseIcon) clearPauseIcon.style.display = 'none'; clearPlayBtn.disabled = true; document.getElementById('stop-button').disabled = true; // Reset progress bar and time displays const progressBar = document.getElementById('progress-bar'); const progressFill = document.getElementById('progress-fill'); if (progressBar) { progressBar.value = 0; delete progressBar.dataset.seeking; } if (progressFill) { progressFill.style.width = '0%'; } const currentTimeElement = document.getElementById('current-time'); const totalTimeElement = document.getElementById('total-time'); if (currentTimeElement) currentTimeElement.textContent = '0:00'; if (totalTimeElement) totalTimeElement.textContent = '0:00'; // Hide loading animation hideLoadingAnimation(); // Show no track message and collapse player document.getElementById('no-track-message').classList.remove('hidden'); document.getElementById('media-player').classList.add('idle'); const gotoArtistBtn = document.getElementById('np-goto-artist'); if (gotoArtistBtn) { gotoArtistBtn.href = '#'; gotoArtistBtn.style.pointerEvents = 'none'; gotoArtistBtn.setAttribute('aria-disabled', 'true'); gotoArtistBtn.tabIndex = -1; } // Reset queue state npQueue = []; npQueueIndex = -1; // Sync expanded player and media session updateNpTrackInfo(); updateNpPlayButton(); updateNpProgress(); renderNpQueue(); updateNpPrevNextButtons(); updateMediaSessionPlaybackState(); stopSidebarVisualizer(); if (npModalOpen) closeNowPlayingModal(); console.log('🧹 Track cleared and media player reset'); } function setPlayingState(playing) { isPlaying = playing; const playButton = document.getElementById('play-button'); // Toggle SVG icons (don't use textContent — it destroys SVG children) const playIcon = playButton.querySelector('.play-icon'); const pauseIcon = playButton.querySelector('.pause-icon'); if (playIcon) playIcon.style.display = playing ? 'none' : ''; if (pauseIcon) pauseIcon.style.display = playing ? '' : 'none'; updateNpPlayButton(); updateMediaSessionPlaybackState(); // Sidebar audio visualizer if (playing) { npInitVisualizer(); startSidebarVisualizer(); } else { stopSidebarVisualizer(); } } async function handlePlayPause() { // Use new streaming system toggle function togglePlayback(); } async function handleStop() { // Tear down any in-flight crossfade so its second audio doesn't keep playing. npCancelCrossfade(); // Use new streaming system stop function await stopStream(); clearTrack(); } function handleVolumeChange(event) { const volume = event.target.value; updateVolumeSliderAppearance(); npPersistVolume(volume); // Update HTML5 audio player volume if (audioPlayer) { audioPlayer.volume = volume / 100; } // Sync modal volume and clear mute state npMuted = false; const npVol = document.getElementById('np-volume-slider'); const npFill = document.getElementById('np-volume-fill'); if (npVol) npVol.value = volume; if (npFill) npFill.style.width = volume + '%'; updateNpMuteIcon(); } function handleProgressBarChange(event) { // Handle seeking in the audio track if (!audioPlayer || !audioPlayer.duration) return; const progress = parseFloat(event.target.value); const newTime = (progress / 100) * audioPlayer.duration; console.log(`🎯 Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`); try { audioPlayer.currentTime = newTime; // Update visual progress immediately const progressFill = document.getElementById('progress-fill'); if (progressFill) { progressFill.style.width = `${progress}%`; } // Update time displays immediately const currentTimeElement = document.getElementById('current-time'); if (currentTimeElement) { currentTimeElement.textContent = formatTime(newTime); } // Sync modal progress const npBar = document.getElementById('np-progress-bar'); const npFill = document.getElementById('np-progress-fill'); const npTime = document.getElementById('np-current-time'); if (npBar) npBar.value = progress; if (npFill) npFill.style.width = progress + '%'; if (npTime) npTime.textContent = formatTime(newTime); } catch (error) { console.warn('⚠️ Seek failed:', error.message); // Reset progress bar to current position const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100; event.target.value = actualProgress; const progressFill = document.getElementById('progress-fill'); if (progressFill) { progressFill.style.width = `${actualProgress}%`; } } } function updateVolumeSliderAppearance() { const slider = document.getElementById('volume-slider'); if (!slider) return; const value = slider.value; slider.style.setProperty('--volume-percent', `${value}%`); } function showLoadingAnimation() { document.getElementById('loading-animation').classList.remove('hidden'); } function hideLoadingAnimation() { document.getElementById('loading-animation').classList.add('hidden'); } function setLoadingProgress(percentage) { const loadingAnimation = document.getElementById('loading-animation'); const progressBar = loadingAnimation.querySelector('.loading-progress'); const loadingText = loadingAnimation.querySelector('.loading-text'); loadingAnimation.classList.remove('hidden'); progressBar.style.width = `${percentage}%`; loadingText.textContent = `${Math.round(percentage)}%`; } // =============================== // STREAMING FUNCTIONALITY // =============================== let _streamLock = false; async function startStream(searchResult) { // Start streaming a track - handles same track toggle and new track streaming try { // Prevent multiple concurrent stream starts (rapid clicking) if (_streamLock) { console.log('⏳ Stream already starting, ignoring duplicate click'); return; } 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; } // Lock to prevent duplicate stream starts _streamLock = true; // 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, image_url: searchResult.image_url || searchResult.album_cover_url || null }); 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.success) { throw new Error(data.error || 'Failed to start streaming'); } console.log("✅ Stream started successfully"); // Start status polling startStreamStatusPolling(); } catch (error) { console.error('Error starting stream:', error); showToast(`Failed to start stream: ${error.message}`, 'error'); hideLoadingAnimation(); clearTrack(); } finally { _streamLock = false; } } function startStreamStatusPolling() { // Start polling for stream status updates with retry logic if (streamStatusPoller) { clearInterval(streamStatusPoller); } // Reset polling state streamPollingRetries = 0; streamPollingInterval = 1000; // Reset to 1-second interval console.log('🔄 Starting enhanced stream status polling'); updateStreamStatus(); // Initial check streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); } function stopStreamStatusPolling() { // Stop polling for stream status updates if (streamStatusPoller) { clearInterval(streamStatusPoller); streamStatusPoller = null; streamPollingRetries = 0; streamPollingInterval = 1000; // Reset interval console.log('⏹️ Stopped stream status polling'); } } // Phase 4: Track last known tool statuses to prevent repeated toasts on terminal states let _lastToolStatus = {}; // Phase 5: Sync/Discovery/Scan WebSocket router functions function updateSyncProgressFromData(data) { const pid = data.playlist_id; const callback = _syncProgressCallbacks[pid]; if (callback) callback(data); } function updateDiscoveryProgressFromData(data) { const id = data.id; const callback = _discoveryProgressCallbacks[id]; if (callback) callback(data); } function updateWatchlistScanFromData(data) { if (!data.success) return; if (_lastWatchlistScanStatus === data.status && data.status !== 'scanning') return; _lastWatchlistScanStatus = data.status; handleWatchlistScanData(data); } function updateMediaScanFromData(data) { if (!data.success || !data.status) return; const status = data.status; const statusKey = status.is_scanning ? 'scanning' : (status.status || 'unknown'); if (_lastMediaScanStatus === statusKey && statusKey !== 'scanning') return; _lastMediaScanStatus = statusKey; const phaseLabel = document.getElementById('media-scan-phase-label'); const progressLabel = document.getElementById('media-scan-progress-label'); const button = document.getElementById('media-scan-btn'); const progressBar = document.getElementById('media-scan-progress-bar'); const statusValue = document.getElementById('media-scan-status'); if (status.is_scanning) { if (phaseLabel) phaseLabel.textContent = 'Media server scanning...'; if (progressLabel) progressLabel.textContent = status.progress_message || 'Scan in progress'; } else if (status.status === 'idle') { if (button) button.disabled = false; if (phaseLabel) phaseLabel.textContent = 'Scan completed successfully'; if (progressBar) progressBar.style.width = '0%'; if (progressLabel) progressLabel.textContent = 'Ready for next scan'; if (statusValue) { statusValue.textContent = 'Idle'; statusValue.style.color = '#b3b3b3'; } showToast('✅ Media scan completed', 'success', 3000); } } let _wishlistAutoProcessingNotified = false; function updateWishlistStatsFromData(data) { // Auto-processing detection: close modal and notify (once only) if (data.is_auto_processing) { if (!_wishlistAutoProcessingNotified) { if (currentPage === 'wishlist') navigateToPage('active-downloads'); showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); _wishlistAutoProcessingNotified = true; } return; } // Reset flag when auto-processing ends _wishlistAutoProcessingNotified = false; // Store latest stats for countdown timer refresh _lastWishlistStats = data; } async function updateStreamStatus() { // Always poll over HTTP: stream state is per-listener (resolved from the // session cookie), and the old global 'tool:stream' socket broadcast could // only see the DEFAULT session — it told every real browser "stopped" // forever while this poller deferred to it. The poller only runs while a // stream is being prepared, so the 1s fetch is negligible. // Poll server for streaming progress and handle state changes with enhanced error recovery try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout const response = await fetch(API.stream.status, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); // Reset retry count on successful response streamPollingRetries = 0; streamPollingInterval = 1000; // Reset to normal interval // Update current stream state currentStream.status = data.status; currentStream.progress = data.progress; switch (data.status) { case 'loading': setLoadingProgress(data.progress); // Update loading text with progress const loadingText = document.querySelector('.loading-text'); if (loadingText && data.progress > 0) { loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; } break; case 'queued': // Show queue status with better messaging const queueText = document.querySelector('.loading-text'); if (queueText) { queueText.textContent = 'Queuing with uploader...'; } setLoadingProgress(0); // Reset progress for queue state break; case 'ready': // Stream is ready - start audio playback console.log('🎵 Stream ready, starting audio playback'); stopStreamStatusPolling(); // Restore player UI if JS state was wiped (e.g. page refresh) if (!currentTrack && data.track_info) { const ti = data.track_info; setTrackInfo({ title: ti.name || ti.title || 'Unknown Track', artist: ti.artist || 'Unknown Artist', album: ti.album || 'Unknown Album', filename: ti.filename || '', is_library: !!ti.is_library, image_url: ti.image_url || null, id: ti.id || null, artist_id: ti.artist_id || null, album_id: ti.album_id || null, }); } await startAudioPlayback(); break; case 'error': console.error('❌ Streaming error:', data.error_message); stopStreamStatusPolling(); hideLoadingAnimation(); showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); clearTrack(); break; case 'stopped': // Handle stopped state — do NOT clear track here; explicit stop (handleStop) // calls clearTrack() directly. Clearing here collapses the player mid-playback // when the backend transitions to 'stopped' after audio naturally ends or during // queue track transitions. console.log('🛑 Stream stopped'); stopStreamStatusPolling(); hideLoadingAnimation(); break; } } catch (error) { streamPollingRetries++; console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message); if (streamPollingRetries >= maxStreamPollingRetries) { // Too many consecutive failures - give up console.error('❌ Stream status polling failed after maximum retries'); stopStreamStatusPolling(); hideLoadingAnimation(); showToast('Lost connection to streaming server', 'error'); clearTrack(); } else { // Implement exponential backoff for retries const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff streamPollingInterval = 1000 * backoffMultiplier; // Restart polling with new interval if (streamStatusPoller) { clearInterval(streamStatusPoller); streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); console.log(`🔄 Retrying stream status polling with ${streamPollingInterval}ms interval`); } } } } // (updateStreamStatusFromData removed: it consumed the global 'tool:stream' // socket broadcast, which could only carry the DEFAULT session's state — every // real browser has its own per-listener stream session, so the broadcast told // everyone "stopped" forever. Stream status is driven by the per-session HTTP // poller (updateStreamStatus) exclusively.) async function startAudioPlayback() { // Start HTML5 audio playback of the streamed file with enhanced state management try { if (!audioPlayer) { throw new Error('Audio player not initialized'); } // Show loading state while preparing audio const loadingText = document.querySelector('.loading-text'); if (loadingText) { loadingText.textContent = 'Preparing playback...'; } // Set audio source with cache-busting timestamp const audioUrl = `/stream/audio?t=${new Date().getTime()}`; console.log(`🎵 Loading audio from: ${audioUrl}`); // Clear any existing source first audioPlayer.pause(); audioPlayer.currentTime = 0; audioPlayer.src = ''; // Set new source audioPlayer.src = audioUrl; audioPlayer.load(); // Force reload // Wait for audio to be ready with promise-based approach await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Audio loading timeout')); }, 15000); // 15-second timeout const onCanPlay = () => { clearTimeout(timeout); audioPlayer.removeEventListener('canplay', onCanPlay); audioPlayer.removeEventListener('error', onError); resolve(); }; const onError = (event) => { clearTimeout(timeout); audioPlayer.removeEventListener('canplay', onCanPlay); audioPlayer.removeEventListener('error', onError); const error = event.target.error || new Error('Audio loading failed'); reject(error); }; audioPlayer.addEventListener('canplay', onCanPlay); audioPlayer.addEventListener('error', onError); // If already ready, resolve immediately if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA onCanPlay(); } }); console.log('✅ Audio loaded and ready for playback'); // Try to start playback with retry logic let retryCount = 0; const maxRetries = 3; while (retryCount < maxRetries) { try { await audioPlayer.play(); console.log('✅ Audio playback started successfully'); // Update UI to playing state hideLoadingAnimation(); setPlayingState(true); // Show media player if hidden const noTrackMessage = document.getElementById('no-track-message'); if (noTrackMessage) { noTrackMessage.classList.add('hidden'); } // Update volume to current slider value const volumeSlider = document.getElementById('volume-slider'); if (volumeSlider) { audioPlayer.volume = volumeSlider.value / 100; } // Enable play/stop buttons const playButton = document.getElementById('play-button'); const stopButton = document.getElementById('stop-button'); if (playButton) playButton.disabled = false; if (stopButton) stopButton.disabled = false; return; // Success! } catch (playError) { retryCount++; console.warn(`⚠️ Audio play attempt ${retryCount} failed:`, playError.message); if (retryCount >= maxRetries) { throw playError; // Re-throw after max retries } // Wait before retry with exponential backoff await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); } } } catch (error) { console.error('❌ Error starting audio playback:', error); hideLoadingAnimation(); // Provide user-friendly error messages let userMessage = 'Playback failed'; if (error.message.includes('no supported source') || error.message.includes('Not supported') || error.message.includes('MEDIA_ELEMENT_ERROR')) { userMessage = 'Audio format not supported by your browser. Try downloading instead.'; } else if (error.message.includes('network') || error.message.includes('fetch')) { userMessage = 'Network error - please check your connection'; } else if (error.message.includes('decode')) { userMessage = 'Audio file is corrupted or incompatible'; } else if (error.message.includes('timeout')) { userMessage = 'Audio loading timeout - file may be too large'; } else if (error.message.includes('AbortError')) { userMessage = 'Playback was interrupted'; } showToast(userMessage, 'error'); // Only clear track if not in queue playback mode — queue handles its own error recovery if (npQueue.length === 0) { 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; // Update progress bar const progressBar = document.getElementById('progress-bar'); const progressFill = document.getElementById('progress-fill'); if (progressBar && !progressBar.dataset.seeking) { progressBar.value = progress; // Update visual progress fill if (progressFill) { progressFill.style.width = `${progress}%`; } } // Update time display 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); } // Sync expanded player modal if (npModalOpen) updateNpProgress(); // Sync lyrics highlight when synced LRC is loaded. if (_npLyricsState.synced && _npLyricsState.lines.length) { _npLyricsHighlight(audioPlayer.currentTime); } } // ───────────────────────────────────────────────────────────────── // Lyrics panel (now-playing modal) // ───────────────────────────────────────────────────────────────── // Module-level state for the currently-loaded lyrics. Reset on each // track change. ``lines`` is an array of {time, text} for synced // lyrics or null for plain text. ``activeIndex`` tracks the last // highlighted line to avoid re-rendering on every timeupdate tick. const _npLyricsState = { trackKey: null, lines: [], synced: false, activeIndex: -1, fetchInFlight: false, autoOpen: false, }; function _npLyricsResetUI() { const content = document.getElementById('np-lyrics-content'); const status = document.getElementById('np-lyrics-status'); if (content) content.innerHTML = '
No lyrics loaded
'; if (status) status.textContent = ''; } function _npLyricsParseLrc(synced) { // Parse a standard LRC string. Lines without a timestamp are // dropped (metadata tags like ``[ti:Title]`` aren't lyrics). The // same line can carry multiple timestamps — emit one entry per // timestamp so seeks land correctly when a chorus repeats. const out = []; if (!synced) return out; const re = /\[(\d+):(\d+(?:\.\d+)?)\]/g; synced.split(/\r?\n/).forEach(raw => { const stamps = []; let m; re.lastIndex = 0; while ((m = re.exec(raw)) !== null) { const minutes = parseInt(m[1], 10); const seconds = parseFloat(m[2]); if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) continue; stamps.push(minutes * 60 + seconds); } if (!stamps.length) return; const text = raw.replace(re, '').trim(); stamps.forEach(t => out.push({ time: t, text })); }); out.sort((a, b) => a.time - b.time); return out; } function _npLyricsRenderSynced(lines) { const content = document.getElementById('np-lyrics-content'); if (!content) return; if (!lines.length) { content.innerHTML = '
No timestamped lyrics for this track
'; return; } content.innerHTML = lines.map((line, idx) => { const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' '; return `
${safe}
`; }).join(''); // Click a line → seek playback to its timestamp (synced lyrics only). content.querySelectorAll('.np-lyrics-line').forEach(el => { el.addEventListener('click', () => { const idx = Number(el.dataset.idx); const line = _npLyricsState.lines[idx]; if (!line || !audioPlayer || !isFinite(line.time)) return; try { audioPlayer.currentTime = line.time; if (audioPlayer.paused) audioPlayer.play().catch(() => {}); } catch (_) {} }); }); } function _npLyricsRenderPlain(text) { const content = document.getElementById('np-lyrics-content'); if (!content) return; const safe = (text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); content.innerHTML = `
${safe.replace(/\n/g, '
')}
`; } function _npLyricsHighlight(currentTime) { const { lines } = _npLyricsState; if (!lines.length) return; let idx = -1; // Binary-search style linear scan — N is small (200 lines max). for (let i = 0; i < lines.length; i++) { if (lines[i].time <= currentTime) idx = i; else break; } if (idx === _npLyricsState.activeIndex) return; _npLyricsState.activeIndex = idx; const content = document.getElementById('np-lyrics-content'); if (!content) return; content.querySelectorAll('.np-lyrics-line').forEach((el, i) => { el.classList.remove('active', 'passed', 'upcoming'); if (i === idx) el.classList.add('active'); else if (i < idx) el.classList.add('passed'); else el.classList.add('upcoming'); }); const activeEl = content.querySelector('.np-lyrics-line.active'); if (activeEl) { // Smooth-scroll the active line into the middle of the lyrics body. const body = document.getElementById('np-lyrics-body'); if (body) { const bodyRect = body.getBoundingClientRect(); const lineRect = activeEl.getBoundingClientRect(); const targetTop = (lineRect.top - bodyRect.top) - (bodyRect.height / 2) + (lineRect.height / 2); body.scrollTo({ top: body.scrollTop + targetTop, behavior: 'smooth' }); } } } function _npLyricsTrackKey(track) { if (!track) return null; return `${track.title || ''}|${track.artist || ''}|${track.album || ''}`; } async function _npLyricsLoadForTrack(track) { const key = _npLyricsTrackKey(track); if (!key) return; if (_npLyricsState.trackKey === key) return; // already loaded if (_npLyricsState.fetchInFlight) return; _npLyricsState.trackKey = key; _npLyricsState.lines = []; _npLyricsState.synced = false; _npLyricsState.activeIndex = -1; _npLyricsResetUI(); const status = document.getElementById('np-lyrics-status'); if (status) status.textContent = 'Fetching…'; _npLyricsState.fetchInFlight = true; try { const resp = await fetch('/api/lyrics/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: _stripSourceIdPrefix(track.title) || '', artist: _stripSourceIdPrefix(track.artist) || '', album: _stripSourceIdPrefix(track.album) || '', duration: Math.round(audioPlayer?.duration || 0), file_path: track.is_library ? track.filename : null, }), }); const data = await resp.json(); if (_npLyricsState.trackKey !== key) return; // track changed mid-fetch if (data && data.success) { if (data.synced) { const parsed = _npLyricsParseLrc(data.synced); if (parsed.length) { _npLyricsState.synced = true; _npLyricsState.lines = parsed; _npLyricsRenderSynced(parsed); if (status) status.textContent = 'Synced'; return; } } if (data.plain) { _npLyricsState.synced = false; _npLyricsState.lines = []; _npLyricsRenderPlain(data.plain); if (status) status.textContent = 'Plain'; return; } } const content = document.getElementById('np-lyrics-content'); if (content) content.innerHTML = '
No lyrics found
'; if (status) status.textContent = ''; } catch (e) { console.debug('lyrics fetch failed:', e); const content = document.getElementById('np-lyrics-content'); if (content) content.innerHTML = '
Lyrics unavailable
'; if (status) status.textContent = ''; } finally { _npLyricsState.fetchInFlight = false; } } function _npLyricsTogglePanel(forceOpen = null) { const panel = document.getElementById('np-lyrics-panel'); const body = document.getElementById('np-lyrics-body'); const toggle = document.getElementById('np-lyrics-toggle'); if (!panel || !body || !toggle) return; const willOpen = forceOpen === null ? body.classList.contains('hidden') : forceOpen; if (willOpen) { body.classList.remove('hidden'); panel.classList.remove('collapsed'); toggle.setAttribute('aria-expanded', 'true'); } else { body.classList.add('hidden'); panel.classList.add('collapsed'); toggle.setAttribute('aria-expanded', 'false'); } } function _npLyricsInit() { const toggle = document.getElementById('np-lyrics-toggle'); if (toggle && !toggle._lyricsBound) { toggle.addEventListener('click', () => _npLyricsTogglePanel()); toggle._lyricsBound = true; } } function onAudioEnded() { // Handle audio playback completion console.log('🏁 Audio playback ended'); setPlayingState(false); // Reset progress to beginning const progressBar = document.getElementById('progress-bar'); const progressFill = document.getElementById('progress-fill'); if (progressBar) { progressBar.value = 0; } if (progressFill) { progressFill.style.width = '0%'; } const currentTimeElement = document.getElementById('current-time'); if (currentTimeElement) { currentTimeElement.textContent = '0:00'; } // If a crossfade is mid-flight it OWNS the advance to the next track — // bail so we don't double-advance (crossfade's npFinishCrossfade → // playQueueItem already handles it). if (npXfadeActive) return; // Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat) // Auto-advance to next track if queue has a next item (guard against race conditions) if (npQueue.length > 0 && !npLoadingQueueItem) { const hasNext = npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'); if (hasNext) { playNextInQueue(); return; } } // Radio mode: auto-fetch similar tracks when queue is exhausted if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) { npFetchRadioTracks(); } } function onAudioError(event) { // Handle audio playback errors const error = event.target.error; console.error('❌ Audio error:', error); // Don't show error toast if it's just a format/codec issue and retrying if (error && error.code) { console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`); // Only show user-facing errors for serious issues if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported console.warn('⚠️ Media format not supported by browser, but streaming may still work'); // Don't clear track or show error - let retry logic handle it return; } } hideLoadingAnimation(); // Only clear track after a short delay to allow for recovery setTimeout(() => { if (audioPlayer && audioPlayer.error) { let userMessage = 'Audio format not supported by your browser. Try downloading instead.'; if (error && error.code) { switch (error.code) { case 1: // MEDIA_ERR_ABORTED userMessage = 'Playback was stopped'; break; case 2: // MEDIA_ERR_NETWORK userMessage = 'Network error - please try again'; break; case 3: // MEDIA_ERR_DECODE userMessage = 'Audio file is corrupted or incompatible'; break; case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED userMessage = 'Audio format not supported by your browser. Try downloading instead.'; break; } } showToast(userMessage, 'error'); // Only clear track if not in queue playback — queue handles its own recovery if (npQueue.length === 0) { clearTrack(); } } }, 2000); } 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')}`; } function formatCountdownTime(seconds) { // Format seconds as countdown timer (e.g., "24m 13s", "2h 15m", "23h 59m") if (seconds === null || seconds === undefined || seconds < 0) return ''; if (seconds === 0) return '0s'; // Show "0s" instead of hiding timer const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}h ${minutes}m`; } else if (minutes > 0) { return `${minutes}m ${secs}s`; } else { return `${secs}s`; } } // =============================== // AUDIO FORMAT SUPPORT DETECTION // =============================== function getFileExtension(filename) { if (!filename) return ''; const ext = filename.toLowerCase().match(/\.([^.]+)$/); return ext ? ext[1] : ''; } function isAudioFormatSupported(filename) { const ext = getFileExtension(filename); const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats const partialSupport = ['flac', 'aac', 'm4a', 'opus', 'webm']; // Test browser support const unsupported = ['wma', 'ape', 'aiff']; // Generally problematic if (supportedFormats.includes(ext)) { return true; } if (partialSupport.includes(ext)) { // Test if browser can actually play this format return canPlayAudioFormat(ext); } return false; // Unsupported formats } function canPlayAudioFormat(extension) { const audio = document.createElement('audio'); const mimeTypes = { 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg; codecs="vorbis"', 'wav': 'audio/wav', 'flac': 'audio/flac', 'aac': 'audio/aac', 'm4a': 'audio/mp4; codecs="mp4a.40.2"', // More specific M4A MIME type 'opus': 'audio/ogg; codecs="opus"', 'webm': 'audio/webm; codecs="opus"', 'wma': 'audio/x-ms-wma' }; const mimeType = mimeTypes[extension]; if (!mimeType) { console.warn(`🎵 [FORMAT CHECK] No MIME type found for extension: ${extension}`); return false; } const canPlay = audio.canPlayType(mimeType); console.log(`🎵 [FORMAT CHECK] ${extension} (${mimeType}): ${canPlay}`); let isSupported = canPlay === 'probably' || canPlay === 'maybe'; // Special handling for M4A - try fallback MIME types if first one fails if (!isSupported && extension === 'm4a') { const fallbackMimeTypes = ['audio/mp4', 'audio/x-m4a', 'audio/aac']; console.log(`🎵 [FORMAT CHECK] M4A failed with primary MIME type, trying fallbacks...`); for (const fallbackMime of fallbackMimeTypes) { const fallbackResult = audio.canPlayType(fallbackMime); console.log(`🎵 [FORMAT CHECK] M4A fallback (${fallbackMime}): ${fallbackResult}`); if (fallbackResult === 'probably' || fallbackResult === 'maybe') { isSupported = true; console.log(`🎵 [FORMAT CHECK] M4A supported with fallback MIME type: ${fallbackMime}`); break; } } } console.log(`🎵 [FORMAT CHECK] ${extension} final support result: ${isSupported}`); return isSupported; } // =============================== // EXPANDED NOW PLAYING MODAL // =============================== let npModalOpen = false; let npRepeatMode = 'off'; // 'off' | 'all' | 'one' let npShuffleOn = false; let npQueue = []; let npQueueIndex = -1; let npMuted = false; let npPreMuteVolume = 70; let npMediaSessionThrottle = 0; let npLoadingQueueItem = false; let npRadioMode = false; let npRecentlyPlayedIds = []; let npAudioContext = null; let npAnalyser = null; let npMediaSource = null; let npVizAnimFrame = null; let npVizInitialized = false; let npCrossfadeOn = false; let npSleepMinutes = 0; // 0 = off let npSleepTimerId = null; function npQueueHasNext() { if (npQueue.length === 0) return false; return npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'); } function npEnsureCurrentTrackInQueue() { if (!currentTrack || !currentTrack.is_library || npQueue.length > 0) return; npQueue.push({ title: currentTrack.title, artist: currentTrack.artist, album: currentTrack.album, file_path: currentTrack.filename || currentTrack.file_path, filename: currentTrack.filename || currentTrack.file_path, is_library: true, image_url: currentTrack.image_url, id: currentTrack.id, artist_id: currentTrack.artist_id, album_id: currentTrack.album_id, bitrate: currentTrack.bitrate, sample_rate: currentTrack.sample_rate }); npQueueIndex = 0; renderNpQueue(); updateNpPrevNextButtons(); } function npSetRadioMode(enabled, options = {}) { const { toast = true, fetchIfNeeded = false } = options; npRadioMode = Boolean(enabled); const radioBtn = document.getElementById('np-radio-btn'); if (radioBtn) { radioBtn.classList.toggle('active', npRadioMode); radioBtn.setAttribute('aria-pressed', npRadioMode ? 'true' : 'false'); radioBtn.title = npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode - auto-add similar tracks'; } if (toast) { showToast(npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode off', 'success'); } // Context label: only set the generic "Radio" if a more specific one (e.g. // " Radio") wasn't already set by the caller. if (npRadioMode) { if (!npPlayContext || !/radio/i.test(npPlayContext)) npSetPlayContext('Radio'); } else if (/radio/i.test(npPlayContext)) { npSetPlayContext(''); } if (npRadioMode && fetchIfNeeded && currentTrack && currentTrack.id && !npLoadingQueueItem && !npQueueHasNext()) { npEnsureCurrentTrackInQueue(); npFetchRadioTracks(); } } function initExpandedPlayer() { const closeBtn = document.getElementById('np-close-btn'); const overlay = document.getElementById('np-modal-overlay'); const playBtn = document.getElementById('np-play-btn'); const stopBtn = document.getElementById('np-stop-btn'); const shuffleBtn = document.getElementById('np-shuffle-btn'); const repeatBtn = document.getElementById('np-repeat-btn'); const muteBtn = document.getElementById('np-mute-btn'); const npProgressBar = document.getElementById('np-progress-bar'); const npVolumeSlider = document.getElementById('np-volume-slider'); if (!overlay) return; // Close handlers closeBtn.addEventListener('click', closeNowPlayingModal); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeNowPlayingModal(); }); // Control handlers playBtn.addEventListener('click', () => { togglePlayback(); }); stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); }); // Click album art → toggle the music-synced visualizer takeover const artContainer = document.getElementById('np-album-art-container'); if (artContainer) { artContainer.addEventListener('click', () => { const on = artContainer.classList.toggle('viz-on'); if (on) { npBuildArtViz(); npInitVisualizer(); npStartVisualizerLoop(); } }); } // Sleep timer — cycles off → 15 → 30 → 60 min → off const sleepBtn = document.getElementById('np-sleep-btn'); if (sleepBtn) sleepBtn.addEventListener('click', npCycleSleepTimer); // Crossfade toggle (real dual-audio crossfade for library tracks) const xfadeBtn = document.getElementById('np-crossfade-btn'); if (xfadeBtn) { try { npCrossfadeOn = localStorage.getItem('soulsync-crossfade') === '1'; } catch (e) {} xfadeBtn.classList.toggle('active', npCrossfadeOn); xfadeBtn.addEventListener('click', () => { npCrossfadeOn = !npCrossfadeOn; xfadeBtn.classList.toggle('active', npCrossfadeOn); try { localStorage.setItem('soulsync-crossfade', npCrossfadeOn ? '1' : '0'); } catch (e) {} }); } shuffleBtn.addEventListener('click', handleNpShuffle); repeatBtn.addEventListener('click', handleNpRepeat); muteBtn.addEventListener('click', handleNpMuteToggle); // Progress bar (mouse) npProgressBar.addEventListener('input', handleNpProgressBarChange); npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; }); npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; }); // Seek hover tooltip — shows the timestamp the cursor is over. const npSeekTip = document.getElementById('np-seek-tip'); if (npSeekTip) { npProgressBar.addEventListener('mousemove', (e) => { if (!audioPlayer || !isFinite(audioPlayer.duration) || audioPlayer.duration <= 0) { npSeekTip.classList.remove('visible'); return; } const rect = npProgressBar.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); npSeekTip.textContent = formatTime(frac * audioPlayer.duration); npSeekTip.style.left = (frac * 100) + '%'; npSeekTip.classList.add('visible'); }); npProgressBar.addEventListener('mouseleave', () => npSeekTip.classList.remove('visible')); } // Progress bar (touch) npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true }); npProgressBar.addEventListener('touchmove', (e) => { const touch = e.touches[0]; const rect = npProgressBar.getBoundingClientRect(); const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100)); npProgressBar.value = pct; npProgressBar.dispatchEvent(new Event('input')); }, { passive: true }); npProgressBar.addEventListener('touchend', () => { delete npProgressBar.dataset.seeking; }, { passive: true }); // Volume slider npVolumeSlider.addEventListener('input', handleNpVolumeChange); // Keyboard shortcuts (global) document.addEventListener('keydown', handlePlayerKeyboardShortcuts); // Make sidebar media player clickable to open modal const mediaPlayer = document.getElementById('media-player'); if (mediaPlayer) { mediaPlayer.style.cursor = 'pointer'; mediaPlayer.addEventListener('click', (e) => { // Don't open modal when clicking controls (let expand-hint through) if (e.target.closest('.play-button, .stop-button, .volume-slider, .volume-control, .progress-bar, .volume-icon, .mini-nav-btn') && !e.target.closest('.expand-hint')) return; if (currentTrack) openNowPlayingModal(); }); } // Prev / Next buttons const prevBtn = document.getElementById('np-prev-btn'); const nextBtn = document.getElementById('np-next-btn'); if (prevBtn) prevBtn.addEventListener('click', () => { playPreviousInQueue(); }); if (nextBtn) nextBtn.addEventListener('click', () => { playNextInQueue(); }); // Queue panel toggle + clear const queueToggle = document.getElementById('np-queue-toggle'); if (queueToggle) { queueToggle.addEventListener('click', () => { const body = document.getElementById('np-queue-body'); if (body) body.classList.toggle('hidden'); queueToggle.classList.toggle('active'); }); } const queueClearBtn = document.getElementById('np-queue-clear'); if (queueClearBtn) queueClearBtn.addEventListener('click', () => { clearQueue(); }); // Radio mode button const radioBtn = document.getElementById('np-radio-btn'); if (radioBtn) { radioBtn.addEventListener('click', () => { npSetRadioMode(!npRadioMode, { fetchIfNeeded: true }); }); } // Action link (Go to Artist) const gotoArtistBtn = document.getElementById('np-goto-artist'); if (gotoArtistBtn) { gotoArtistBtn.style.textDecoration = 'none'; gotoArtistBtn.style.color = 'inherit'; } // Buffering state listeners on audioPlayer if (audioPlayer) { audioPlayer.addEventListener('waiting', () => { const ring = document.getElementById('np-buffering-ring'); if (ring) ring.classList.remove('hidden'); }); audioPlayer.addEventListener('canplay', () => { const ring = document.getElementById('np-buffering-ring'); if (ring) ring.classList.add('hidden'); }); audioPlayer.addEventListener('playing', () => { const ring = document.getElementById('np-buffering-ring'); if (ring) ring.classList.add('hidden'); }); } // Init Media Session API initMediaSession(); } function openNowPlayingModal() { const overlay = document.getElementById('np-modal-overlay'); if (!overlay) return; npModalOpen = true; overlay.classList.remove('hidden'); document.body.style.overflow = 'hidden'; syncExpandedPlayerUI(); // Bind lyrics toggle (idempotent — only attaches once). Lyrics // fetch fires from setTrackInfo so by the time the modal opens // the panel is usually already populated. _npLyricsInit(); // Start visualizer if already playing if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); } } function closeNowPlayingModal() { const overlay = document.getElementById('np-modal-overlay'); if (!overlay) return; npModalOpen = false; overlay.classList.add('hidden'); document.body.style.overflow = ''; npStopVisualizerLoop(); } function syncExpandedPlayerUI() { if (!npModalOpen) return; // Track info updateNpTrackInfo(); // Play state updateNpPlayButton(); // Progress updateNpProgress(); // Volume const sidebarVol = document.getElementById('volume-slider'); const npVol = document.getElementById('np-volume-slider'); const npVolFill = document.getElementById('np-volume-fill'); if (sidebarVol && npVol) { npVol.value = sidebarVol.value; if (npVolFill) npVolFill.style.width = sidebarVol.value + '%'; } // Visualizer const viz = document.getElementById('np-visualizer'); if (viz) viz.classList.toggle('playing', isPlaying); // Album-art scale-on-play (Phase A restyle — CSS keys off .np-modal.playing) const npModalEl = document.querySelector('.np-modal'); if (npModalEl) npModalEl.classList.toggle('playing', isPlaying); // Queue renderNpQueue(); updateNpPrevNextButtons(); } function updateNpTrackInfo() { const titleEl = document.getElementById('np-track-title'); const artistEl = document.getElementById('np-artist-name'); const albumEl = document.getElementById('np-album-name'); const artImg = document.getElementById('np-album-art'); const artPlaceholder = document.getElementById('np-album-art-placeholder'); const badgesEl = document.getElementById('np-format-badges'); const actionBtns = document.getElementById('np-action-buttons'); if (!titleEl) return; // Sidebar album art const sidebarArt = document.getElementById('sidebar-album-art'); if (currentTrack) { // Track text transition animation const textEls = [titleEl, artistEl, albumEl]; const oldTitle = titleEl.textContent; const newTitle = currentTrack.title || 'Unknown Track'; const trackChanged = oldTitle !== newTitle && oldTitle !== 'No track'; titleEl.textContent = newTitle; artistEl.textContent = currentTrack.artist || 'Unknown Artist'; albumEl.textContent = currentTrack.album || 'Unknown Album'; if (trackChanged) { textEls.forEach(el => { el.classList.remove('np-text-transition'); void el.offsetWidth; // force reflow el.classList.add('np-text-transition'); }); } // Album art (modal + sidebar) + ambient glow extraction const artUrl = getNpAlbumArtUrl(); if (artUrl && artImg) { // Only set crossOrigin for external URLs — local paths break with CORS headers if (artUrl.startsWith('http')) { artImg.crossOrigin = 'anonymous'; } else { artImg.removeAttribute('crossOrigin'); } artImg.src = artUrl; artImg.classList.remove('hidden'); artImg.onerror = () => { artImg.classList.add('hidden'); npResetAmbientGlow(); }; artImg.onload = () => { npExtractAmbientColor(artImg); }; } else if (artImg) { artImg.classList.add('hidden'); npResetAmbientGlow(); } if (sidebarArt) { if (artUrl) { sidebarArt.src = artUrl; sidebarArt.style.display = ''; sidebarArt.onerror = () => { sidebarArt.src = '/static/trans2.png'; }; } else { sidebarArt.src = '/static/trans2.png'; } } // Format badges (richer: include bitrate/sample_rate) if (badgesEl) { badgesEl.innerHTML = ''; const filename = currentTrack.filename || ''; if (filename) { const ext = getFileExtension(filename); if (ext) { let label = ext.toUpperCase(); if (currentTrack.sample_rate) { const khz = (currentTrack.sample_rate / 1000); label += ' ' + (khz % 1 === 0 ? khz.toFixed(0) : khz.toFixed(1)) + 'kHz'; } const badge = document.createElement('span'); badge.className = 'np-format-badge' + (ext === 'flac' ? ' flac' : ''); badge.textContent = label; badgesEl.appendChild(badge); } if (currentTrack.bitrate) { const brBadge = document.createElement('span'); brBadge.className = 'np-format-badge'; brBadge.textContent = currentTrack.bitrate + 'k'; badgesEl.appendChild(brBadge); } } } // Action buttons visibility if (actionBtns) { const hasArtist = currentTrack.artist_id; actionBtns.classList.toggle('hidden', !hasArtist); } // Track recently played for radio mode if (currentTrack.id && !npRecentlyPlayedIds.includes(currentTrack.id)) { npRecentlyPlayedIds.push(currentTrack.id); if (npRecentlyPlayedIds.length > 50) npRecentlyPlayedIds.shift(); } } else { titleEl.textContent = 'No track'; artistEl.textContent = 'Unknown Artist'; albumEl.textContent = 'Unknown Album'; if (artImg) artImg.classList.add('hidden'); if (sidebarArt) sidebarArt.src = '/static/trans2.png'; if (badgesEl) badgesEl.innerHTML = ''; if (actionBtns) actionBtns.classList.add('hidden'); npResetAmbientGlow(); } } function npExtractAmbientColor(imgEl) { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 64; canvas.height = 64; ctx.drawImage(imgEl, 0, 0, 64, 64); const data = ctx.getImageData(0, 0, 64, 64).data; // Dominant VIBRANT color, not a flat average (averaging muddies to // grey-brown). Bin colors into a coarse 4-bit-per-channel histogram, // weight each bin by saturation² × pixel-count so a punchy accent in // the cover wins over a large dull background. Apple-Music-style. const bins = new Map(); for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3]; if (a < 128) continue; const max = Math.max(r, g, b), min = Math.min(r, g, b); const brightness = (r + g + b) / 3; if (brightness < 24 || brightness > 240) continue; // skip near-black/white const sat = max === 0 ? 0 : (max - min) / max; // 0..1 const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); const weight = (0.15 + sat * sat) ; // floor so greys still count a little const bin = bins.get(key); if (bin) { bin.r += r; bin.g += g; bin.b += b; bin.n++; bin.w += weight; } else bins.set(key, { r, g, b, n: 1, w: weight }); } let best = null, bestScore = -1; for (const bin of bins.values()) { const score = bin.w; // saturation-weighted population if (score > bestScore) { bestScore = score; best = bin; } } if (best) { let r = Math.round(best.r / best.n); let g = Math.round(best.g / best.n); let b = Math.round(best.b / best.n); // Nudge toward vivid: lift saturation/brightness a touch so the // glow reads as a color, not a wash. [r, g, b] = npPunchUpColor(r, g, b); const modal = document.querySelector('.np-modal'); if (modal) { modal.style.setProperty('--np-ambient-r', r); modal.style.setProperty('--np-ambient-g', g); modal.style.setProperty('--np-ambient-b', b); } } } catch (e) { // Cross-origin or canvas error — ignore silently } } // Lift a color toward vividness for the ambient glow (boost saturation, // floor brightness) without fully desaturating dark/pastel covers. function npPunchUpColor(r, g, b) { const max = Math.max(r, g, b), min = Math.min(r, g, b); if (max === min) return [r, g, b]; // grey — leave it // Pull each channel away from the mid to boost perceived saturation ~1.3x. const mid = (max + min) / 2; const boost = 1.3; let nr = Math.round(mid + (r - mid) * boost); let ng = Math.round(mid + (g - mid) * boost); let nb = Math.round(mid + (b - mid) * boost); // Floor overall brightness so very dark covers still glow. const bright = (nr + ng + nb) / 3; if (bright < 70) { const lift = 70 / Math.max(bright, 1); nr *= lift; ng *= lift; nb *= lift; } const clamp = v => Math.max(0, Math.min(255, Math.round(v))); return [clamp(nr), clamp(ng), clamp(nb)]; } // ── Crossfade engine (library tracks only) ── // EXPERIMENTAL. Real crossfade needs two tracks playing at once; /stream/audio // only serves the ONE current track (single global stream_state), so we use a // dedicated /stream/library-audio endpoint + a second