Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.
1. Play buttons across track-list modals
Every track row in the download-missing modals (Spotify, Tidal,
YouTube, services, artist album, wishlist download-missing) and
the add-to-wishlist modal now carries a play button. Click runs
playTrackFromLibraryOrStream:
- If the track has a local file_path → playLibraryTrack
- Else POST /api/stats/resolve-track to find it in the library
by title + artist → playLibraryTrack
- Else fall back to _gsPlayTrack streaming
Backend ownership response gains track_id / title / file_path so
the wishlist modal's owned tracks can hand the right metadata
to the player without an extra round trip.
The add-to-wishlist modal previously showed the play button only
on owned tracks; now the button is unconditional so the streaming
fallback can take over for unowned ones (matches the standard
pattern from the rest of the app).
2. Clean media-player display titles
YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
source-side identifier into the filename field as
<source_id>||<display> so download() can recover it later. The
media player's track-title renderer never knew about this
convention and showed strings like
"wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
now-playing UI. extractTrackTitle and setTrackInfo now strip the
<id>|| prefix defensively so any path into the player gets a
clean display.
Local library playback also fetches canonical metadata from
/api/stats/resolve-track when track.id is present so title /
artist / album / album art come straight from the SoulSync DB
instead of whatever the caller passed in. Falls back silently
to caller values on any error so playback never blocks on the
metadata fetch.
3. Lyrics panel + View Artist close
New collapsed lyrics panel between the playback controls and
queue panel. POST /api/lyrics/fetch (new backend endpoint)
prefers the local .lrc / .txt sidecar files SoulSync writes
during post-processing so downloaded tracks resolve lyrics with
zero network hits; falls back to LRClib exact-match (when album
+ duration are available) then to LRClib search.
Synced LRC results are parsed (handles multi-stamp lines for
repeated choruses), and the active line highlights + smooth-
scrolls into the middle of the viewport on every audio
timeupdate. Plain-text results render without highlighting.
Per-track cache prevents re-fetching when the user revisits the
same track. Lyrics fetch is fire-and-forget — failure shows
"No lyrics found" without ever blocking playback.
View Artist on the expanded player now calls
closeNowPlayingModal before navigating; the modal was previously
sitting open over the artist page, hiding it. Handler is bound
once and is a no-op when no artist_id is attached.
CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.
WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
2663 lines
100 KiB
JavaScript
2663 lines
100 KiB
JavaScript
// 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('ended', onAudioEnded);
|
|
audioPlayer.addEventListener('error', onAudioError);
|
|
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
|
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
|
|
|
// Set initial volume
|
|
audioPlayer.volume = 0.7; // 70%
|
|
if (volumeSlider) volumeSlider.value = 70;
|
|
}
|
|
|
|
// 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(); });
|
|
}
|
|
|
|
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 ``<source_id>||<display>`` 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 ``<source_id>||<display>``
|
|
// 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;
|
|
|
|
const trackTitleElement = document.getElementById('track-title');
|
|
const trackTitle = _stripSourceIdPrefix(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 = _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();
|
|
|
|
// 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;
|
|
|
|
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';
|
|
// 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() {
|
|
// Use new streaming system stop function
|
|
await stopStream();
|
|
clearTrack();
|
|
}
|
|
|
|
function handleVolumeChange(event) {
|
|
const volume = event.target.value;
|
|
updateVolumeSliderAppearance();
|
|
|
|
// 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() {
|
|
if (socketConnected) return; // WebSocket handles this
|
|
// 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`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateStreamStatusFromData(data) {
|
|
const prev = _lastToolStatus['stream'];
|
|
_lastToolStatus['stream'] = data.status;
|
|
// Skip repeated terminal states to avoid duplicate toasts/actions
|
|
if (prev !== undefined && data.status === prev && data.status !== 'loading' && data.status !== 'queued') return;
|
|
|
|
currentStream.status = data.status;
|
|
currentStream.progress = data.progress;
|
|
|
|
switch (data.status) {
|
|
case 'loading':
|
|
setLoadingProgress(data.progress);
|
|
const loadingText = document.querySelector('.loading-text');
|
|
if (loadingText && data.progress > 0) {
|
|
loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`;
|
|
}
|
|
break;
|
|
case 'queued':
|
|
const queueText = document.querySelector('.loading-text');
|
|
if (queueText) {
|
|
queueText.textContent = 'Queuing with uploader...';
|
|
}
|
|
setLoadingProgress(0);
|
|
break;
|
|
case 'ready':
|
|
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,
|
|
});
|
|
}
|
|
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':
|
|
// Do NOT clear track here — explicit stop (handleStop) calls clearTrack() directly.
|
|
// Clearing here collapses the player after audio naturally ends or during queue transitions.
|
|
console.log('🛑 Stream stopped');
|
|
stopStreamStatusPolling();
|
|
hideLoadingAnimation();
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 = '<div class="np-lyrics-empty">No lyrics loaded</div>';
|
|
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 = '<div class="np-lyrics-empty">No timestamped lyrics for this track</div>';
|
|
return;
|
|
}
|
|
content.innerHTML = lines.map((line, idx) => {
|
|
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' ';
|
|
return `<div class="np-lyrics-line" data-idx="${idx}">${safe}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function _npLyricsRenderPlain(text) {
|
|
const content = document.getElementById('np-lyrics-content');
|
|
if (!content) return;
|
|
const safe = (text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
content.innerHTML = `<div class="np-lyrics-plain">${safe.replace(/\n/g, '<br>')}</div>`;
|
|
}
|
|
|
|
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 = '<div class="np-lyrics-empty">No lyrics found</div>';
|
|
if (status) status.textContent = '';
|
|
} catch (e) {
|
|
console.debug('lyrics fetch failed:', e);
|
|
const content = document.getElementById('np-lyrics-content');
|
|
if (content) content.innerHTML = '<div class="np-lyrics-empty">Lyrics unavailable</div>';
|
|
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';
|
|
}
|
|
|
|
// 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;
|
|
|
|
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(); });
|
|
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; });
|
|
|
|
// 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', () => {
|
|
npRadioMode = !npRadioMode;
|
|
radioBtn.classList.toggle('active', npRadioMode);
|
|
showToast(npRadioMode ? 'Radio mode on — similar tracks will auto-queue' : 'Radio mode off', 'success');
|
|
// Immediately fetch radio tracks if turned on while playing with empty/exhausted queue
|
|
if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) {
|
|
const hasNext = npQueue.length > 0 && (npShuffleOn
|
|
? npQueue.length > 1
|
|
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
|
|
if (!hasNext) {
|
|
// Add current track to queue first so it appears as "now playing" in context
|
|
if (npQueue.length === 0 && currentTrack.is_library) {
|
|
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
|
|
});
|
|
npQueueIndex = 0;
|
|
renderNpQueue();
|
|
updateNpPrevNextButtons();
|
|
}
|
|
npFetchRadioTracks();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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 = 50;
|
|
canvas.height = 50;
|
|
ctx.drawImage(imgEl, 0, 0, 50, 50);
|
|
const data = ctx.getImageData(0, 0, 50, 50).data;
|
|
let rSum = 0, gSum = 0, bSum = 0, count = 0;
|
|
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];
|
|
const brightness = (r + g + b) / 3;
|
|
if (brightness > 20 && brightness < 230) {
|
|
rSum += r; gSum += g; bSum += b; count++;
|
|
}
|
|
}
|
|
if (count > 0) {
|
|
const modal = document.querySelector('.np-modal');
|
|
if (modal) {
|
|
modal.style.setProperty('--np-ambient-r', Math.round(rSum / count));
|
|
modal.style.setProperty('--np-ambient-g', Math.round(gSum / count));
|
|
modal.style.setProperty('--np-ambient-b', Math.round(bSum / count));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Cross-origin or canvas error — ignore silently
|
|
}
|
|
}
|
|
|
|
function npResetAmbientGlow() {
|
|
const modal = document.querySelector('.np-modal');
|
|
if (modal) {
|
|
modal.style.setProperty('--np-ambient-r', '29');
|
|
modal.style.setProperty('--np-ambient-g', '185');
|
|
modal.style.setProperty('--np-ambient-b', '84');
|
|
}
|
|
}
|
|
|
|
function updateNpPlayButton() {
|
|
const playIcon = document.querySelector('.np-icon-play');
|
|
const pauseIcon = document.querySelector('.np-icon-pause');
|
|
if (playIcon && pauseIcon) {
|
|
playIcon.classList.toggle('hidden', isPlaying);
|
|
pauseIcon.classList.toggle('hidden', !isPlaying);
|
|
}
|
|
|
|
const viz = document.getElementById('np-visualizer');
|
|
if (viz) viz.classList.toggle('playing', isPlaying);
|
|
|
|
// Drive Web Audio visualizer (only when modal is open to save CPU)
|
|
if (isPlaying && npModalOpen) {
|
|
npInitVisualizer();
|
|
npStartVisualizerLoop();
|
|
} else {
|
|
npStopVisualizerLoop();
|
|
}
|
|
}
|
|
|
|
function updateNpProgress() {
|
|
if (!npModalOpen || !audioPlayer) return;
|
|
|
|
const npProgressBar = document.getElementById('np-progress-bar');
|
|
const npProgressFill = document.getElementById('np-progress-fill');
|
|
const npCurrentTime = document.getElementById('np-current-time');
|
|
const npTotalTime = document.getElementById('np-total-time');
|
|
|
|
if (audioPlayer.duration) {
|
|
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
|
if (npProgressBar && !npProgressBar.dataset.seeking) {
|
|
npProgressBar.value = progress;
|
|
}
|
|
if (npProgressFill) npProgressFill.style.width = progress + '%';
|
|
if (npCurrentTime) npCurrentTime.textContent = formatTime(audioPlayer.currentTime);
|
|
if (npTotalTime) npTotalTime.textContent = formatTime(audioPlayer.duration);
|
|
} else {
|
|
if (npProgressBar) npProgressBar.value = 0;
|
|
if (npProgressFill) npProgressFill.style.width = '0%';
|
|
if (npCurrentTime) npCurrentTime.textContent = '0:00';
|
|
if (npTotalTime) npTotalTime.textContent = '0:00';
|
|
}
|
|
}
|
|
|
|
function handleNpProgressBarChange(event) {
|
|
if (!audioPlayer || !audioPlayer.duration) return;
|
|
const progress = parseFloat(event.target.value);
|
|
const newTime = (progress / 100) * audioPlayer.duration;
|
|
|
|
try {
|
|
audioPlayer.currentTime = newTime;
|
|
|
|
// Sync sidebar progress
|
|
const sidebarBar = document.getElementById('progress-bar');
|
|
const sidebarFill = document.getElementById('progress-fill');
|
|
if (sidebarBar) sidebarBar.value = progress;
|
|
if (sidebarFill) sidebarFill.style.width = progress + '%';
|
|
|
|
// Sync modal progress fill
|
|
const npFill = document.getElementById('np-progress-fill');
|
|
if (npFill) npFill.style.width = progress + '%';
|
|
|
|
// Update time displays
|
|
const sidebarTime = document.getElementById('current-time');
|
|
const npTime = document.getElementById('np-current-time');
|
|
if (sidebarTime) sidebarTime.textContent = formatTime(newTime);
|
|
if (npTime) npTime.textContent = formatTime(newTime);
|
|
} catch (error) {
|
|
console.warn('Seek failed:', error.message);
|
|
}
|
|
}
|
|
|
|
function handleNpVolumeChange(event) {
|
|
const volume = parseInt(event.target.value);
|
|
if (audioPlayer) audioPlayer.volume = volume / 100;
|
|
|
|
// Sync sidebar volume slider
|
|
const sidebarVol = document.getElementById('volume-slider');
|
|
if (sidebarVol) {
|
|
sidebarVol.value = volume;
|
|
sidebarVol.style.setProperty('--volume-percent', volume + '%');
|
|
}
|
|
|
|
// Update modal volume fill
|
|
const npFill = document.getElementById('np-volume-fill');
|
|
if (npFill) npFill.style.width = volume + '%';
|
|
|
|
// Update mute state
|
|
npMuted = volume === 0;
|
|
updateNpMuteIcon();
|
|
}
|
|
|
|
function handleNpMuteToggle() {
|
|
const npVol = document.getElementById('np-volume-slider');
|
|
if (!npVol) return;
|
|
|
|
if (npMuted) {
|
|
// Unmute — restore previous volume
|
|
npVol.value = npPreMuteVolume;
|
|
npVol.dispatchEvent(new Event('input'));
|
|
npMuted = false;
|
|
} else {
|
|
// Mute — save current volume, set to 0
|
|
npPreMuteVolume = parseInt(npVol.value) || 70;
|
|
npVol.value = 0;
|
|
npVol.dispatchEvent(new Event('input'));
|
|
npMuted = true;
|
|
}
|
|
updateNpMuteIcon();
|
|
}
|
|
|
|
function updateNpMuteIcon() {
|
|
const muteBtn = document.getElementById('np-mute-btn');
|
|
const volIcon = muteBtn ? muteBtn.querySelector('.np-icon-vol') : null;
|
|
const mutedIcon = muteBtn ? muteBtn.querySelector('.np-icon-muted') : null;
|
|
if (volIcon && mutedIcon) {
|
|
volIcon.classList.toggle('hidden', npMuted);
|
|
mutedIcon.classList.toggle('hidden', !npMuted);
|
|
}
|
|
if (muteBtn) muteBtn.classList.toggle('muted', npMuted);
|
|
}
|
|
|
|
function handleNpShuffle() {
|
|
npShuffleOn = !npShuffleOn;
|
|
const btn = document.getElementById('np-shuffle-btn');
|
|
if (btn) btn.classList.toggle('active', npShuffleOn);
|
|
updateNpPrevNextButtons();
|
|
}
|
|
|
|
function handleNpRepeat() {
|
|
const badge = document.getElementById('np-repeat-one-badge');
|
|
if (npRepeatMode === 'off') {
|
|
npRepeatMode = 'all';
|
|
if (audioPlayer) audioPlayer.loop = false;
|
|
} else if (npRepeatMode === 'all') {
|
|
npRepeatMode = 'one';
|
|
if (audioPlayer) audioPlayer.loop = true;
|
|
} else {
|
|
npRepeatMode = 'off';
|
|
if (audioPlayer) audioPlayer.loop = false;
|
|
}
|
|
const btn = document.getElementById('np-repeat-btn');
|
|
if (btn) btn.classList.toggle('active', npRepeatMode !== 'off');
|
|
if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one');
|
|
updateNpPrevNextButtons();
|
|
}
|
|
|
|
// ===============================
|
|
// QUEUE MANAGEMENT
|
|
// ===============================
|
|
|
|
function addToQueue(track) {
|
|
npQueue.push(track);
|
|
showToast('Added to queue', 'success');
|
|
renderNpQueue();
|
|
updateNpPrevNextButtons();
|
|
// If nothing is currently playing, auto-play the first queued track
|
|
if (!currentTrack) {
|
|
playQueueItem(npQueue.length - 1);
|
|
}
|
|
}
|
|
|
|
function removeFromQueue(index) {
|
|
if (index < 0 || index >= npQueue.length) return;
|
|
const wasCurrentTrack = (index === npQueueIndex);
|
|
npQueue.splice(index, 1);
|
|
// Adjust current index
|
|
if (npQueue.length === 0) {
|
|
npQueueIndex = -1;
|
|
// Current track keeps playing but queue is now empty — that's OK
|
|
} else if (index < npQueueIndex) {
|
|
npQueueIndex--;
|
|
} else if (wasCurrentTrack) {
|
|
// Removed the currently playing item
|
|
if (npQueueIndex >= npQueue.length) {
|
|
npQueueIndex = npQueue.length - 1;
|
|
}
|
|
// Play the next track at the adjusted index
|
|
playQueueItem(npQueueIndex);
|
|
}
|
|
renderNpQueue();
|
|
updateNpPrevNextButtons();
|
|
}
|
|
|
|
function clearQueue() {
|
|
npQueue = [];
|
|
npQueueIndex = -1;
|
|
renderNpQueue();
|
|
updateNpPrevNextButtons();
|
|
}
|
|
|
|
function playNextInQueue() {
|
|
if (npQueue.length === 0) return;
|
|
if (npShuffleOn) {
|
|
// Pick a random index that is not the current one
|
|
const candidates = [];
|
|
for (let i = 0; i < npQueue.length; i++) {
|
|
if (i !== npQueueIndex) candidates.push(i);
|
|
}
|
|
if (candidates.length === 0) return;
|
|
const next = candidates[Math.floor(Math.random() * candidates.length)];
|
|
playQueueItem(next);
|
|
} else {
|
|
const next = npQueueIndex + 1;
|
|
if (next >= npQueue.length) {
|
|
// End of queue — repeat-all wraps to start
|
|
if (npRepeatMode === 'all') {
|
|
playQueueItem(0);
|
|
}
|
|
return;
|
|
}
|
|
playQueueItem(next);
|
|
}
|
|
}
|
|
|
|
function playPreviousInQueue() {
|
|
// If more than 3 seconds in, restart current track
|
|
if (audioPlayer && audioPlayer.currentTime > 3) {
|
|
audioPlayer.currentTime = 0;
|
|
if (audioPlayer.paused) audioPlayer.play();
|
|
return;
|
|
}
|
|
if (npQueue.length === 0) return;
|
|
const prev = npQueueIndex - 1;
|
|
if (prev < 0) {
|
|
// At start — restart current track
|
|
if (audioPlayer) {
|
|
audioPlayer.currentTime = 0;
|
|
if (audioPlayer.paused) audioPlayer.play();
|
|
}
|
|
return;
|
|
}
|
|
playQueueItem(prev);
|
|
}
|
|
|
|
async function playQueueItem(index) {
|
|
if (index < 0 || index >= npQueue.length) return;
|
|
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
|
|
npLoadingQueueItem = true;
|
|
npQueueIndex = index;
|
|
const track = npQueue[index];
|
|
|
|
try {
|
|
if (track.is_library) {
|
|
// Library track playback flow
|
|
await stopStream();
|
|
setTrackInfo({
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
filename: track.file_path,
|
|
is_library: true,
|
|
image_url: track.image_url,
|
|
id: track.id,
|
|
artist_id: track.artist_id,
|
|
album_id: track.album_id,
|
|
bitrate: track.bitrate,
|
|
sample_rate: track.sample_rate
|
|
});
|
|
showLoadingAnimation();
|
|
|
|
const response = await fetch('/api/library/play', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
file_path: track.file_path,
|
|
title: track.title || '',
|
|
artist: track.artist || '',
|
|
album: track.album || ''
|
|
})
|
|
});
|
|
const result = await response.json();
|
|
if (!result.success) throw new Error(result.error || 'Failed to start playback');
|
|
// Re-apply repeat-one loop property
|
|
if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one');
|
|
await startAudioPlayback();
|
|
} else {
|
|
// Non-library (stream) tracks cannot be queued for auto-advance
|
|
// Just show track info — the stream flow handles its own playback
|
|
setTrackInfo({
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
filename: track.filename || track.file_path,
|
|
is_library: false,
|
|
image_url: track.image_url,
|
|
id: track.id,
|
|
artist_id: track.artist_id,
|
|
album_id: track.album_id,
|
|
bitrate: track.bitrate,
|
|
sample_rate: track.sample_rate
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Queue playback error:', error);
|
|
showToast(`Skipping track: ${error.message}`, 'error');
|
|
hideLoadingAnimation();
|
|
// Auto-skip to next track on failure instead of stopping the queue
|
|
npLoadingQueueItem = false;
|
|
const nextIdx = npQueueIndex + 1;
|
|
if (nextIdx < npQueue.length) {
|
|
setTimeout(() => playQueueItem(nextIdx), 500);
|
|
}
|
|
return;
|
|
} finally {
|
|
npLoadingQueueItem = false;
|
|
}
|
|
|
|
renderNpQueue();
|
|
updateNpPrevNextButtons();
|
|
}
|
|
|
|
function renderNpQueue() {
|
|
const listEl = document.getElementById('np-queue-list');
|
|
const emptyEl = document.getElementById('np-queue-empty');
|
|
const countEl = document.getElementById('np-queue-count');
|
|
if (!listEl) return;
|
|
|
|
if (countEl) countEl.textContent = npQueue.length > 0 ? `(${npQueue.length})` : '';
|
|
|
|
if (npQueue.length === 0) {
|
|
listEl.innerHTML = '';
|
|
if (emptyEl) emptyEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
if (emptyEl) emptyEl.classList.add('hidden');
|
|
listEl.innerHTML = '';
|
|
|
|
npQueue.forEach((track, i) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
|
|
item.onclick = () => playQueueItem(i);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'np-queue-item-info';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'np-queue-item-title';
|
|
title.textContent = track.title || 'Unknown Track';
|
|
|
|
const artist = document.createElement('div');
|
|
artist.className = 'np-queue-item-artist';
|
|
artist.textContent = track.artist || 'Unknown Artist';
|
|
|
|
info.appendChild(title);
|
|
info.appendChild(artist);
|
|
item.appendChild(info);
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'np-queue-item-remove';
|
|
removeBtn.innerHTML = '✕';
|
|
removeBtn.title = 'Remove from queue';
|
|
removeBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
removeFromQueue(i);
|
|
};
|
|
item.appendChild(removeBtn);
|
|
|
|
listEl.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function updateNpPrevNextButtons() {
|
|
const canPrev = npQueueIndex > 0 || (audioPlayer && audioPlayer.currentTime > 3);
|
|
const canNext = npQueue.length > 0 && (npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
|
|
|
|
// Full Now Playing modal buttons
|
|
const prevBtn = document.getElementById('np-prev-btn');
|
|
const nextBtn = document.getElementById('np-next-btn');
|
|
if (prevBtn) prevBtn.disabled = !canPrev;
|
|
if (nextBtn) nextBtn.disabled = !canNext;
|
|
|
|
// Mini player buttons
|
|
const miniPrevBtn = document.getElementById('mini-prev-btn');
|
|
const miniNextBtn = document.getElementById('mini-next-btn');
|
|
if (miniPrevBtn) miniPrevBtn.disabled = !canPrev;
|
|
if (miniNextBtn) miniNextBtn.disabled = !canNext;
|
|
}
|
|
|
|
function handlePlayerKeyboardShortcuts(event) {
|
|
// Don't intercept when typing in inputs or when non-player modals are open
|
|
const tag = document.activeElement.tagName.toLowerCase();
|
|
if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement.isContentEditable) return;
|
|
|
|
// Only handle when player modal is open OR when no other modal is visible
|
|
const otherModals = document.querySelectorAll('.modal-overlay:not(.hidden):not(#np-modal-overlay)');
|
|
if (otherModals.length > 0 && !npModalOpen) return;
|
|
|
|
switch (event.key) {
|
|
case ' ':
|
|
if (!currentTrack) return;
|
|
event.preventDefault();
|
|
togglePlayback();
|
|
break;
|
|
case 'ArrowLeft':
|
|
if (!audioPlayer || !audioPlayer.duration) return;
|
|
event.preventDefault();
|
|
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 5);
|
|
break;
|
|
case 'ArrowRight':
|
|
if (!audioPlayer || !audioPlayer.duration) return;
|
|
event.preventDefault();
|
|
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 5);
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
if (audioPlayer) {
|
|
const newVol = Math.min(1, audioPlayer.volume + 0.05);
|
|
audioPlayer.volume = newVol;
|
|
syncVolumeUI(Math.round(newVol * 100));
|
|
}
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
if (audioPlayer) {
|
|
const newVol = Math.max(0, audioPlayer.volume - 0.05);
|
|
audioPlayer.volume = newVol;
|
|
syncVolumeUI(Math.round(newVol * 100));
|
|
}
|
|
break;
|
|
case 'm':
|
|
case 'M':
|
|
if (npModalOpen) handleNpMuteToggle();
|
|
break;
|
|
case 'Escape':
|
|
if (npModalOpen) closeNowPlayingModal();
|
|
break;
|
|
default:
|
|
return; // Don't prevent default for unhandled keys
|
|
}
|
|
}
|
|
|
|
function syncVolumeUI(volumePercent) {
|
|
// Sync both sidebar and modal volume UIs
|
|
const sidebarVol = document.getElementById('volume-slider');
|
|
const npVol = document.getElementById('np-volume-slider');
|
|
const npFill = document.getElementById('np-volume-fill');
|
|
|
|
if (sidebarVol) {
|
|
sidebarVol.value = volumePercent;
|
|
sidebarVol.style.setProperty('--volume-percent', volumePercent + '%');
|
|
}
|
|
if (npVol) npVol.value = volumePercent;
|
|
if (npFill) npFill.style.width = volumePercent + '%';
|
|
}
|
|
|
|
function getNpAlbumArtUrl() {
|
|
if (!currentTrack) return null;
|
|
return currentTrack.image_url || currentTrack.album_cover_url || currentTrack.thumb_url || null;
|
|
}
|
|
|
|
// ===============================
|
|
// WEB AUDIO VISUALIZER
|
|
// ===============================
|
|
|
|
function npInitVisualizer() {
|
|
if (npVizInitialized || !audioPlayer) return;
|
|
try {
|
|
if (!npAudioContext) {
|
|
npAudioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
if (!npMediaSource) {
|
|
npMediaSource = npAudioContext.createMediaElementSource(audioPlayer);
|
|
npAnalyser = npAudioContext.createAnalyser();
|
|
npAnalyser.fftSize = 64;
|
|
npAnalyser.smoothingTimeConstant = 0.8;
|
|
npMediaSource.connect(npAnalyser);
|
|
npAnalyser.connect(npAudioContext.destination);
|
|
}
|
|
npVizInitialized = true;
|
|
} catch (e) {
|
|
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
|
|
// Mark as CSS fallback
|
|
const viz = document.getElementById('np-visualizer');
|
|
if (viz) viz.classList.add('np-viz-css-fallback');
|
|
npVizInitialized = true; // don't retry
|
|
}
|
|
}
|
|
|
|
function npStartVisualizerLoop() {
|
|
if (npVizAnimFrame) return; // Already running
|
|
if (!npAnalyser) return; // No analyser — CSS fallback handles it
|
|
|
|
if (npAudioContext && npAudioContext.state === 'suspended') {
|
|
npAudioContext.resume();
|
|
}
|
|
|
|
const bars = document.querySelectorAll('.np-viz-bar');
|
|
if (bars.length === 0) return;
|
|
const bufferLength = npAnalyser.frequencyBinCount;
|
|
const dataArray = new Uint8Array(bufferLength);
|
|
|
|
function draw() {
|
|
npVizAnimFrame = requestAnimationFrame(draw);
|
|
npAnalyser.getByteFrequencyData(dataArray);
|
|
|
|
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
|
|
const binCount = Math.min(bufferLength - 1, 7);
|
|
for (let i = 0; i < bars.length; i++) {
|
|
const binIndex = Math.min(i + 1, bufferLength - 1);
|
|
const value = dataArray[binIndex] / 255; // 0..1
|
|
const scale = Math.max(0.08, value); // minimum visible height
|
|
bars[i].style.transform = `scaleY(${scale})`;
|
|
}
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function npStopVisualizerLoop() {
|
|
if (npVizAnimFrame) {
|
|
cancelAnimationFrame(npVizAnimFrame);
|
|
npVizAnimFrame = null;
|
|
}
|
|
// Reset bars to min
|
|
const bars = document.querySelectorAll('.np-viz-bar');
|
|
bars.forEach(bar => { bar.style.transform = 'scaleY(0.125)'; });
|
|
}
|
|
|
|
// ===============================
|
|
// SIDEBAR AUDIO VISUALIZER
|
|
// ===============================
|
|
|
|
let sidebarVizAnimFrame = null;
|
|
let sidebarVisualizerType = 'bars'; // bars | wave | spectrum | mirror | equalizer | none
|
|
const SIDEBAR_VIZ_BAR_COUNT = 32;
|
|
|
|
let _sidebarVizBuiltType = null;
|
|
|
|
function buildSidebarVizElements(type) {
|
|
const container = document.getElementById('sidebar-visualizer');
|
|
if (!container) return;
|
|
if (_sidebarVizBuiltType === type && container.children.length > 0) return;
|
|
_sidebarVizBuiltType = type;
|
|
container.innerHTML = '';
|
|
container.className = 'sidebar-visualizer';
|
|
|
|
if (type === 'bars') {
|
|
container.classList.add('viz-bars');
|
|
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'sidebar-viz-bar';
|
|
container.appendChild(bar);
|
|
}
|
|
} else if (type === 'wave' || type === 'spectrum') {
|
|
container.classList.add('viz-canvas');
|
|
const canvas = document.createElement('canvas');
|
|
canvas.className = 'sidebar-viz-canvas';
|
|
canvas.width = 10;
|
|
canvas.height = 600;
|
|
container.appendChild(canvas);
|
|
} else if (type === 'mirror') {
|
|
container.classList.add('viz-mirror');
|
|
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'sidebar-viz-mirror-bar';
|
|
container.appendChild(bar);
|
|
}
|
|
} else if (type === 'equalizer') {
|
|
container.classList.add('viz-equalizer');
|
|
for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'sidebar-viz-eq-wrap';
|
|
const bar = document.createElement('div');
|
|
bar.className = 'sidebar-viz-eq-bar';
|
|
const peak = document.createElement('div');
|
|
peak.className = 'sidebar-viz-eq-peak';
|
|
wrap.appendChild(bar);
|
|
wrap.appendChild(peak);
|
|
container.appendChild(wrap);
|
|
}
|
|
}
|
|
}
|
|
|
|
function startSidebarVisualizer() {
|
|
const type = sidebarVisualizerType;
|
|
if (type === 'none') return;
|
|
|
|
const container = document.getElementById('sidebar-visualizer');
|
|
if (!container) return;
|
|
|
|
buildSidebarVizElements(type);
|
|
container.classList.add('active');
|
|
|
|
if (sidebarVizAnimFrame) return;
|
|
if (!npAnalyser) return;
|
|
|
|
const bufferLength = npAnalyser.frequencyBinCount;
|
|
const dataArray = new Uint8Array(bufferLength);
|
|
const hueStart = 200, hueRange = 160;
|
|
|
|
// Helper: average frequency bins for a given segment index
|
|
function getBinValue(i, count) {
|
|
const binsPerSeg = Math.max(1, Math.floor((bufferLength - 1) / count));
|
|
let sum = 0;
|
|
const start = i * binsPerSeg + 1;
|
|
for (let b = 0; b < binsPerSeg; b++) sum += dataArray[Math.min(start + b, bufferLength - 1)];
|
|
return (sum / binsPerSeg) / 255;
|
|
}
|
|
|
|
// ── Bars ──
|
|
if (type === 'bars') {
|
|
const bars = container.querySelectorAll('.sidebar-viz-bar');
|
|
if (bars.length === 0) return;
|
|
function drawBars() {
|
|
sidebarVizAnimFrame = requestAnimationFrame(drawBars);
|
|
npAnalyser.getByteFrequencyData(dataArray);
|
|
for (let i = 0; i < bars.length; i++) {
|
|
const value = getBinValue(i, bars.length);
|
|
const scale = Math.max(0.08, value);
|
|
const hue = (hueStart + (i / bars.length) * hueRange + value * 30) % 360;
|
|
bars[i].style.transform = `scaleX(${scale})`;
|
|
bars[i].style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
|
|
}
|
|
}
|
|
drawBars();
|
|
|
|
// ── Wave ──
|
|
} else if (type === 'wave') {
|
|
const canvas = container.querySelector('.sidebar-viz-canvas');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
let hueOffset = 0;
|
|
function drawWave() {
|
|
sidebarVizAnimFrame = requestAnimationFrame(drawWave);
|
|
const ch = container.clientHeight;
|
|
if (ch > 0 && canvas.height !== ch) canvas.height = ch;
|
|
npAnalyser.getByteFrequencyData(dataArray);
|
|
const w = canvas.width, h = canvas.height;
|
|
if (h === 0) return;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
let totalEnergy = 0;
|
|
for (let i = 1; i < bufferLength; i++) totalEnergy += dataArray[i];
|
|
const avgEnergy = totalEnergy / (bufferLength - 1) / 255;
|
|
hueOffset = (hueOffset + 0.5) % 360;
|
|
|
|
const segments = 64;
|
|
ctx.lineWidth = 3;
|
|
ctx.lineCap = 'round';
|
|
ctx.beginPath();
|
|
for (let i = 0; i <= segments; i++) {
|
|
const y = (i / segments) * h;
|
|
const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1);
|
|
const value = dataArray[binIdx] / 255;
|
|
const x = (w / 2) + Math.sin((i / segments) * Math.PI * 4 + Date.now() * 0.003) * value * (w - 2) * 0.4;
|
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
}
|
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
grad.addColorStop(0, `hsla(${hueOffset + 200}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`);
|
|
grad.addColorStop(0.5, `hsla(${hueOffset + 280}, 80%, 55%, ${0.3 + avgEnergy * 0.7})`);
|
|
grad.addColorStop(1, `hsla(${hueOffset + 360}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`);
|
|
ctx.strokeStyle = grad;
|
|
ctx.stroke();
|
|
ctx.lineWidth = 6;
|
|
ctx.globalAlpha = 0.15 + avgEnergy * 0.2;
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
drawWave();
|
|
|
|
// ── Spectrum (mountain/terrain fill) ──
|
|
} else if (type === 'spectrum') {
|
|
const canvas = container.querySelector('.sidebar-viz-canvas');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
let hueOffset = 0;
|
|
// Smoothed values for fluid motion
|
|
const smoothed = new Float32Array(64);
|
|
|
|
function drawSpectrum() {
|
|
sidebarVizAnimFrame = requestAnimationFrame(drawSpectrum);
|
|
const ch = container.clientHeight;
|
|
if (ch > 0 && canvas.height !== ch) canvas.height = ch;
|
|
npAnalyser.getByteFrequencyData(dataArray);
|
|
const w = canvas.width, h = canvas.height;
|
|
if (h === 0) return;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
hueOffset = (hueOffset + 0.3) % 360;
|
|
const segments = smoothed.length;
|
|
|
|
// Smooth the frequency data
|
|
for (let i = 0; i < segments; i++) {
|
|
const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1);
|
|
const target = dataArray[binIdx] / 255;
|
|
smoothed[i] += (target - smoothed[i]) * 0.25;
|
|
}
|
|
|
|
// Draw filled mountain shape from left edge
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
for (let i = 0; i <= segments; i++) {
|
|
const y = (i / segments) * h;
|
|
const value = i < segments ? smoothed[i] : smoothed[segments - 1];
|
|
const x = value * w * 0.95;
|
|
ctx.lineTo(x, y);
|
|
}
|
|
ctx.lineTo(0, h);
|
|
ctx.closePath();
|
|
|
|
// Gradient fill
|
|
const fillGrad = ctx.createLinearGradient(0, 0, 0, h);
|
|
fillGrad.addColorStop(0, `hsla(${hueOffset + 200}, 85%, 55%, 0.7)`);
|
|
fillGrad.addColorStop(0.25, `hsla(${hueOffset + 240}, 80%, 50%, 0.6)`);
|
|
fillGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 85%, 50%, 0.65)`);
|
|
fillGrad.addColorStop(0.75, `hsla(${hueOffset + 330}, 80%, 50%, 0.6)`);
|
|
fillGrad.addColorStop(1, `hsla(${hueOffset + 360}, 85%, 55%, 0.7)`);
|
|
ctx.fillStyle = fillGrad;
|
|
ctx.fill();
|
|
|
|
// Bright edge line
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
for (let i = 0; i <= segments; i++) {
|
|
const y = (i / segments) * h;
|
|
const value = i < segments ? smoothed[i] : smoothed[segments - 1];
|
|
ctx.lineTo(value * w * 0.95, y);
|
|
}
|
|
const lineGrad = ctx.createLinearGradient(0, 0, 0, h);
|
|
lineGrad.addColorStop(0, `hsla(${hueOffset + 200}, 90%, 70%, 0.9)`);
|
|
lineGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 90%, 65%, 0.9)`);
|
|
lineGrad.addColorStop(1, `hsla(${hueOffset + 360}, 90%, 70%, 0.9)`);
|
|
ctx.strokeStyle = lineGrad;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
|
|
// Outer glow
|
|
ctx.lineWidth = 4;
|
|
ctx.globalAlpha = 0.2;
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
drawSpectrum();
|
|
|
|
// ── Mirror (bars from center outward) ──
|
|
} else if (type === 'mirror') {
|
|
const bars = container.querySelectorAll('.sidebar-viz-mirror-bar');
|
|
if (bars.length === 0) return;
|
|
function drawMirror() {
|
|
sidebarVizAnimFrame = requestAnimationFrame(drawMirror);
|
|
npAnalyser.getByteFrequencyData(dataArray);
|
|
const half = Math.floor(bars.length / 2);
|
|
for (let i = 0; i < half; i++) {
|
|
const value = getBinValue(i, half);
|
|
const scale = Math.max(0.06, value);
|
|
const hue = (hueStart + (i / half) * hueRange + value * 30) % 360;
|
|
const color = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
|
|
// Top half — mirror index from center
|
|
const topIdx = half - 1 - i;
|
|
const bottomIdx = half + i;
|
|
bars[topIdx].style.transform = `scaleX(${scale})`;
|
|
bars[topIdx].style.backgroundColor = color;
|
|
if (bottomIdx < bars.length) {
|
|
bars[bottomIdx].style.transform = `scaleX(${scale})`;
|
|
bars[bottomIdx].style.backgroundColor = color;
|
|
}
|
|
}
|
|
}
|
|
drawMirror();
|
|
|
|
// ── Equalizer (bars with falling peak indicators) ──
|
|
} else if (type === 'equalizer') {
|
|
const wraps = container.querySelectorAll('.sidebar-viz-eq-wrap');
|
|
if (wraps.length === 0) return;
|
|
const peaks = new Float32Array(wraps.length);
|
|
const peakVelocity = new Float32Array(wraps.length);
|
|
|
|
function drawEqualizer() {
|
|
sidebarVizAnimFrame = requestAnimationFrame(drawEqualizer);
|
|
npAnalyser.getByteFrequencyData(dataArray);
|
|
for (let i = 0; i < wraps.length; i++) {
|
|
const value = getBinValue(i, wraps.length);
|
|
const scale = Math.max(0.06, value);
|
|
const hue = (hueStart + (i / wraps.length) * hueRange + value * 30) % 360;
|
|
const barEl = wraps[i].querySelector('.sidebar-viz-eq-bar');
|
|
const peakEl = wraps[i].querySelector('.sidebar-viz-eq-peak');
|
|
|
|
barEl.style.transform = `scaleX(${scale})`;
|
|
barEl.style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`;
|
|
|
|
// Peak hold with gravity
|
|
if (value > peaks[i]) {
|
|
peaks[i] = value;
|
|
peakVelocity[i] = 0;
|
|
} else {
|
|
peakVelocity[i] += 0.002; // gravity
|
|
peaks[i] = Math.max(0, peaks[i] - peakVelocity[i]);
|
|
}
|
|
const peakPos = Math.max(0.06, peaks[i]);
|
|
peakEl.style.left = `${peakPos * 100}%`;
|
|
peakEl.style.backgroundColor = `hsla(${hue}, 90%, 75%, ${0.6 + peaks[i] * 0.4})`;
|
|
peakEl.style.boxShadow = `0 0 4px hsla(${hue}, 90%, 70%, ${peaks[i] * 0.5})`;
|
|
}
|
|
}
|
|
drawEqualizer();
|
|
}
|
|
}
|
|
|
|
function stopSidebarVisualizer() {
|
|
if (sidebarVizAnimFrame) {
|
|
cancelAnimationFrame(sidebarVizAnimFrame);
|
|
sidebarVizAnimFrame = null;
|
|
}
|
|
const container = document.getElementById('sidebar-visualizer');
|
|
if (container) {
|
|
container.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// Listen for visualizer type changes in settings — use isPlaying (not wasRunning)
|
|
// so switching from 'none' to a real type while music plays starts the visualizer
|
|
document.addEventListener('change', (e) => {
|
|
if (e.target.id === 'sidebar-visualizer-type') {
|
|
const newType = e.target.value;
|
|
stopSidebarVisualizer();
|
|
_sidebarVizBuiltType = null; // force rebuild for new type
|
|
sidebarVisualizerType = newType;
|
|
if (isPlaying && newType !== 'none') {
|
|
npInitVisualizer();
|
|
startSidebarVisualizer();
|
|
}
|
|
}
|
|
});
|
|
|
|
// ===============================
|
|
// RADIO MODE
|
|
// ===============================
|
|
|
|
async function npFetchRadioTracks() {
|
|
if (!currentTrack || !currentTrack.id) return;
|
|
try {
|
|
npLoadingQueueItem = true;
|
|
const excludeIds = npRecentlyPlayedIds.join(',');
|
|
const resp = await fetch(`/api/library/radio?track_id=${currentTrack.id}&limit=50&exclude=${encodeURIComponent(excludeIds)}`);
|
|
if (!resp.ok) {
|
|
console.warn('Radio endpoint returned', resp.status);
|
|
npLoadingQueueItem = false;
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
// Bail if radio was toggled off during the fetch
|
|
if (!npRadioMode) { npLoadingQueueItem = false; return; }
|
|
if (data.tracks && data.tracks.length > 0) {
|
|
data.tracks.forEach(t => {
|
|
npQueue.push({
|
|
title: t.title || 'Unknown Track',
|
|
artist: t.artist || 'Unknown Artist',
|
|
album: t.album || 'Unknown Album',
|
|
file_path: t.file_path,
|
|
filename: t.file_path,
|
|
is_library: true,
|
|
image_url: t.image_url || null,
|
|
id: t.id,
|
|
artist_id: t.artist_id,
|
|
album_id: t.album_id,
|
|
bitrate: t.bitrate,
|
|
sample_rate: t.sample_rate
|
|
});
|
|
});
|
|
showToast(`Radio: Added ${data.tracks.length} similar tracks`, 'success');
|
|
renderNpQueue();
|
|
updateNpPrevNextButtons();
|
|
npLoadingQueueItem = false;
|
|
// Only auto-advance if nothing is currently playing (triggered by onAudioEnded)
|
|
if (!isPlaying) {
|
|
playNextInQueue();
|
|
}
|
|
} else {
|
|
showToast('Radio: No similar tracks found', 'info');
|
|
npLoadingQueueItem = false;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Radio fetch error:', e);
|
|
npLoadingQueueItem = false;
|
|
}
|
|
}
|
|
|
|
// Media Session API
|
|
function initMediaSession() {
|
|
if (!('mediaSession' in navigator)) return;
|
|
|
|
navigator.mediaSession.setActionHandler('play', () => {
|
|
if (audioPlayer && currentTrack) {
|
|
audioPlayer.play().then(() => setPlayingState(true));
|
|
}
|
|
});
|
|
navigator.mediaSession.setActionHandler('pause', () => {
|
|
if (audioPlayer) {
|
|
audioPlayer.pause();
|
|
setPlayingState(false);
|
|
}
|
|
});
|
|
navigator.mediaSession.setActionHandler('stop', () => {
|
|
handleStop();
|
|
});
|
|
navigator.mediaSession.setActionHandler('seekbackward', () => {
|
|
if (audioPlayer && audioPlayer.duration) {
|
|
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
|
|
}
|
|
});
|
|
navigator.mediaSession.setActionHandler('seekforward', () => {
|
|
if (audioPlayer && audioPlayer.duration) {
|
|
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
|
|
}
|
|
});
|
|
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
|
if (npQueue.length > 0) playPreviousInQueue();
|
|
});
|
|
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
|
if (npQueue.length > 0) playNextInQueue();
|
|
});
|
|
}
|
|
|
|
function updateMediaSessionMetadata() {
|
|
if (!('mediaSession' in navigator) || !currentTrack) return;
|
|
const artwork = [];
|
|
const artUrl = getNpAlbumArtUrl();
|
|
if (artUrl) artwork.push({ src: artUrl, sizes: '512x512', type: 'image/jpeg' });
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: currentTrack.title || 'Unknown Track',
|
|
artist: currentTrack.artist || 'Unknown Artist',
|
|
album: currentTrack.album || 'Unknown Album',
|
|
artwork: artwork
|
|
});
|
|
}
|
|
|
|
function updateMediaSessionPlaybackState() {
|
|
if (!('mediaSession' in navigator)) return;
|
|
if (!currentTrack) {
|
|
navigator.mediaSession.playbackState = 'none';
|
|
} else {
|
|
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
|
}
|
|
}
|
|
|
|
// ===============================
|