/** * Freedify - Music Streaming PWA * Enhanced search with albums, artists, playlists, and Spotify URL support */ // ========== STATE ========== const state = { queue: [], currentIndex: -1, isPlaying: false, searchType: 'track', detailTracks: [], // Tracks in current detail view repeatMode: 'none', // 'none' | 'all' | 'one' volume: parseFloat(localStorage.getItem('freedify_volume')) || 1, muted: false, crossfadeDuration: 1, // seconds (when crossfade is enabled) crossfadeEnabled: localStorage.getItem('freedify_crossfade') === 'true', // Crossfade toggle playlists: JSON.parse(localStorage.getItem('freedify_playlists') || '[]'), // User playlists scrobbledCurrent: false, // Track if current song was scrobbled listenBrainzConfig: { valid: false, username: null }, // LB status hiResMode: localStorage.getItem('freedify_hires') !== 'false', // Hi-Res 24-bit mode (Default True) sortOrder: 'newest', // 'newest' or 'oldest' for album sorting lastSearchResults: [], // Store last search results for re-rendering lastSearchType: 'track', // Store last search type }; // ========== DOM ELEMENTS ========== // App.js v0106L - Robust Proxy Cleanup console.log("Freedify v0106L Loaded - Robust Proxy Cleanup"); // Helper for multiple selectors (Fix for ReferenceError: $$ is not defined) const $ = (selector) => document.querySelector(selector); const $$ = (selector) => document.querySelectorAll(selector); // Global Event Delegation for Detail Tracks (Fixes click issues) document.addEventListener('click', (e) => { // Check if click is inside detail-tracks const trackItem = e.target.closest('#detail-tracks .track-item'); if (!trackItem) return; // Don't play if clicking buttons if (e.target.closest('.download-btn') || e.target.closest('.delete-track-btn') || e.target.closest('.info-btn')) return; const index = parseInt(trackItem.dataset.index, 10); if (isNaN(index)) return; // Auto-queue logic const sourceTracks = (state.detailTracks && state.detailTracks.length > 0) ? state.detailTracks : []; if (sourceTracks.length === 0) return; // Don't auto-queue podcast episodes - let user view details and explicitly play const clickedTrack = sourceTracks[index]; if (clickedTrack && clickedTrack.source === 'podcast') { // Show the podcast modal instead of queuing showPodcastModal(encodeURIComponent(JSON.stringify(clickedTrack))); return; } const remainingTracks = sourceTracks.slice(index); state.queue = remainingTracks; state.currentIndex = 0; showToast(`Queueing ${remainingTracks.length} tracks...`); updateQueueUI(); // Check if this track is already preloaded - use it instantly! if (preloadedTrackId === clickedTrack.id && preloadedReady && preloadedPlayer) { console.log('Using preloaded track (detail click):', clickedTrack.name); preloadedTrackId = null; preloadedReady = false; updatePlayerUI(); updateFullscreenUI(clickedTrack); performGaplessSwitch(); updateFormatBadge(getActivePlayer().src); setTimeout(preloadNextTrack, 500); } else { loadTrack(clickedTrack); } }); const searchInput = $('#search-input'); const searchClear = $('#search-clear'); const typeBtns = $$('.type-btn'); const resultsSection = $('#results-section'); const resultsContainer = $('#results-container'); const detailView = $('#detail-view'); const detailInfo = $('#detail-info'); const detailTracks = $('#detail-tracks'); const backBtn = $('#back-btn'); const queueAllBtn = $('#queue-all-btn'); const shuffleBtn = $('#shuffle-btn'); const queueSection = $('#queue-section'); const queueContainer = $('#queue-container'); const queueClose = $('#queue-close'); const queueClear = $('#queue-clear'); const queueCount = $('#queue-count'); const queueBtn = $('#queue-btn'); // Fullscreen Elements const fsToggleBtn = $('#fs-toggle-btn'); const fullscreenPlayer = $('#fullscreen-player'); const fsCloseBtn = $('#fs-close-btn'); const fsArt = $('#fs-art'); const fsTitle = $('#fs-title'); const fsArtist = $('#fs-artist'); const fsCurrentTime = $('#fs-current-time'); const fsDuration = $('#fs-duration'); const fsProgressBar = $('#fs-progress-bar'); const fsPlayBtn = $('#fs-play-btn'); const fsPrevBtn = $('#fs-prev-btn'); const fsNextBtn = $('#fs-next-btn'); const loadingOverlay = $('#loading-overlay'); const loadingText = $('#loading-text'); const errorMessage = $('#error-message'); const errorText = $('#error-text'); const errorRetry = $('#error-retry'); const playerBar = $('#player-bar'); const playerArt = $('#player-art'); const playerTitle = $('#player-title'); const playerArtist = $('#player-artist'); const playerAlbum = $('#player-album'); const playerYear = $('#player-year'); const playBtn = $('#play-btn'); const prevBtn = $('#prev-btn'); const nextBtn = $('#next-btn'); const shuffleQueueBtn = $('#shuffle-queue-btn'); const repeatBtn = $('#repeat-btn'); const progressBar = $('#progress-bar'); const currentTime = $('#current-time'); const duration = $('#duration'); const audioPlayer = $('#audio-player'); const audioPlayer2 = $('#audio-player-2'); const miniPlayerBtn = $('#mini-player-btn'); let pipWindow = null; // Crossfade / Gapless state let activePlayer = 1; // 1 or 2, which player is currently active let crossfadeEnabled = localStorage.getItem('freedify_crossfade') === 'true'; let CROSSFADE_DURATION = 1000; // Default: 1 second. Options: 500, 1000, 2000 let crossfadeTimeout = null; let preloadedPlayer = null; // Ready player with next track loaded let preloadedReady = false; // True when preloaded track has fired canplaythrough // Volume Controls const volumeSlider = $('#volume-slider'); const muteBtn = $('#mute-btn'); // Toast & Shortcuts const toastContainer = $('#toast-container'); const shortcutsHelp = $('#shortcuts-help'); const shortcutsClose = $('#shortcuts-close'); // ========== SEARCH ========== let searchTimeout = null; // Only search on Enter key press (not as-you-type to avoid rate limiting) searchInput.addEventListener('input', (e) => { // Just clear empty state when typing if (!e.target.value.trim()) { showEmptyState(); } }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(searchTimeout); const query = searchInput.value.trim(); if (query) { performSearch(query); } searchInput.blur(); } }); searchClear.addEventListener('click', () => { searchInput.value = ''; showEmptyState(); searchInput.focus(); }); const searchMoreBtn = $('#search-more-btn'); const searchMoreMenu = $('#search-more-menu'); // Toggle Search More Menu if (searchMoreBtn) { searchMoreBtn.addEventListener('click', (e) => { e.stopPropagation(); searchMoreMenu.classList.toggle('hidden'); }); } // Close menu when clicking elsewhere document.addEventListener('click', (e) => { if (searchMoreMenu && !searchMoreMenu.contains(e.target) && e.target !== searchMoreBtn) { searchMoreMenu.classList.add('hidden'); } }); // Search type selector // Re-select all type buttons including new menu items const allTypeBtns = document.querySelectorAll('.type-btn, .type-btn-menu'); allTypeBtns.forEach(btn => { btn.addEventListener('click', () => { if (btn.id === 'search-more-btn') return; // Skip the toggle button itself allTypeBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // If it's a menu item, highlight the "More" button too as a visual indicator if (btn.classList.contains('type-btn-menu')) { searchMoreBtn.classList.add('active'); searchMoreMenu.classList.add('hidden'); // Close menu on selection } state.searchType = btn.dataset.type; // Special types if (state.searchType === 'favorites') { renderPlaylistsView(); return; } else if (state.searchType === 'rec') { renderRecommendations(); return; } const query = searchInput.value.trim(); if (query) performSearch(query); }); }); // Sort Filter Removed // Crossfade Toggle const crossfadeCheckbox = $('#crossfade-checkbox'); if (crossfadeCheckbox) { // Initialize from state crossfadeCheckbox.checked = state.crossfadeEnabled; crossfadeCheckbox.addEventListener('change', () => { state.crossfadeEnabled = crossfadeCheckbox.checked; localStorage.setItem('freedify_crossfade', state.crossfadeEnabled); showToast(state.crossfadeEnabled ? 'Crossfade enabled' : 'Crossfade disabled'); }); } // ========== PLAYLIST MANAGEMENT ========== function savePlaylists() { localStorage.setItem('freedify_playlists', JSON.stringify(state.playlists)); } function createPlaylist(name, tracks = []) { const playlist = { id: 'playlist_' + Date.now(), name: name, created: new Date().toISOString(), tracks: tracks.map(t => ({ id: t.id, name: t.name, artists: t.artists, album: t.album || '', album_art: t.album_art || t.image || '/static/icon.svg', isrc: t.isrc || t.id, duration: t.duration || '0:00' })) }; state.playlists.push(playlist); savePlaylists(); showToast(`Created playlist "${name}"`); return playlist; } function addToPlaylist(playlistId, trackOrTracks) { const playlist = state.playlists.find(p => p.id === playlistId); if (!playlist) return; const tracksToAdd = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; let addedCount = 0; tracksToAdd.forEach(track => { // Avoid duplicates if (playlist.tracks.some(t => t.id === track.id)) return; playlist.tracks.push({ id: track.id, name: track.name, artists: track.artists, album: track.album || '', album_art: track.album_art || track.image || '/static/icon.svg', isrc: track.isrc || track.id, duration: track.duration || '0:00' }); addedCount++; }); if (addedCount > 0) { savePlaylists(); showToast(`Added ${addedCount} tracks to "${playlist.name}"`); } else { showToast('Tracks already in playlist'); } } function deleteFromPlaylist(playlistId, trackId) { const playlist = state.playlists.find(p => p.id === playlistId); if (!playlist) return; const idx = playlist.tracks.findIndex(t => t.id === trackId); if (idx !== -1) { playlist.tracks.splice(idx, 1); savePlaylists(); showToast('Track removed'); // Refresh view if currently viewing this playlist if (state.currentPlaylistView === playlistId) { showPlaylistDetail(playlist); } } } // Expose for use in detail view window.deleteFromPlaylist = deleteFromPlaylist; function deletePlaylist(playlistId) { state.playlists = state.playlists.filter(p => p.id !== playlistId); savePlaylists(); showToast('Playlist deleted'); renderPlaylistsView(); } function renderPlaylistsView() { hideLoading(); if (state.playlists.length === 0) { resultsContainer.innerHTML = `
â¤ī¸

No saved playlists yet

Import a Spotify playlist and click "Save to Playlist"

`; return; } const grid = document.createElement('div'); grid.className = 'results-grid'; state.playlists.forEach(playlist => { const trackCount = playlist.tracks.length; const coverArt = playlist.tracks[0]?.album_art || '/static/icon.svg'; grid.innerHTML += `
${playlist.name}
${playlist.name}
${trackCount} track${trackCount !== 1 ? 's' : ''}
`; }); resultsContainer.innerHTML = ''; resultsContainer.appendChild(grid); // Click handlers grid.querySelectorAll('.playlist-item').forEach(el => { el.addEventListener('click', (e) => { if (e.target.closest('.delete-playlist-btn')) { e.stopPropagation(); const id = el.dataset.playlistId; if (confirm('Delete this playlist?')) { deletePlaylist(id); } return; } const playlist = state.playlists.find(p => p.id === el.dataset.playlistId); if (playlist) { showPlaylistDetail(playlist); } }); }); } function showPlaylistDetail(playlist) { // Track which playlist is being viewed state.currentPlaylistView = playlist.id; // Reuse the existing detail view const albumData = { id: playlist.id, name: playlist.name, artists: `${playlist.tracks.length} tracks`, image: playlist.tracks[0]?.album_art || '/static/icon.svg', is_playlist: true, is_user_playlist: true // Flag to indicate this is a user-created playlist (for delete buttons) }; showDetailView(albumData, playlist.tracks); } async function performSearch(query, append = false) { if (!query) return; // Track search state for Load More if (!append) { state.searchOffset = 0; state.lastSearchQuery = query; } showLoading(append ? 'Loading more...' : `Searching for "${query}"...`); try { const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${state.searchType}&offset=${state.searchOffset}`); const data = await response.json(); if (!response.ok) throw new Error(data.detail || 'Search failed'); hideLoading(); // Check if it was a Spotify URL // Check if it was a Spotify/Imported URL if (data.is_url) { // Auto-open detail view for albums/playlists if (data.tracks && (data.type === 'album' || data.type === 'playlist' || data.type === 'artist')) { showDetailView(data.results[0], data.tracks); return; } // Auto-play single track (e.g. YouTube link) if (data.results && data.results.length === 1 && data.type === 'track') { const track = data.results[0]; playTrack(track); showToast(`Playing imported track: ${track.name}`); // Also render it so they can see it } } renderResults(data.results, data.type || state.searchType, append); // Update offset for next load state.searchOffset += data.results.length; // Show/hide Load More button const loadMoreBtn = $('#load-more-btn'); if (loadMoreBtn) { if (data.results.length >= 20) { loadMoreBtn.classList.remove('hidden'); } else { loadMoreBtn.classList.add('hidden'); } } } catch (error) { console.error('Search error:', error); showError(error.message || 'Search failed. Please try again.'); } } function renderResults(results, type, append = false) { const loadMoreBtn = $('#load-more-btn'); // Store results for re-rendering (when sort changes) if (!append) { state.lastSearchResults = results || []; state.lastSearchType = type; } else if (results) { state.lastSearchResults = [...state.lastSearchResults, ...results]; } if (!results || results.length === 0) { if (!append) { resultsContainer.innerHTML = `
🔍

No results found

`; if (loadMoreBtn) loadMoreBtn.classList.add('hidden'); } return; } let grid; // Helper to get or create Load More button let persistentLoadMoreBtn = document.getElementById('load-more-btn'); if (persistentLoadMoreBtn) { persistentLoadMoreBtn.remove(); // Rescue it } else { // Create fresh if missing (e.g. after view switch) persistentLoadMoreBtn = document.createElement('button'); persistentLoadMoreBtn.id = 'load-more-btn'; persistentLoadMoreBtn.className = 'load-more-btn hidden'; persistentLoadMoreBtn.textContent = 'Load More Results'; persistentLoadMoreBtn.addEventListener('click', () => { if (state.lastSearchQuery) { performSearch(state.lastSearchQuery, true); } }); } if (append) { // Get existing grid or create new grid = resultsContainer.querySelector('.results-grid') || resultsContainer.querySelector('.results-list'); if (!grid) { grid = document.createElement('div'); // Use list layout for tracks, grid for others grid.className = (type === 'track') ? 'results-list' : 'results-grid'; resultsContainer.innerHTML = ''; resultsContainer.appendChild(grid); } } else { grid = document.createElement('div'); // Use list layout for tracks, grid for others grid.className = (type === 'track') ? 'results-list' : 'results-grid'; resultsContainer.innerHTML = ''; resultsContainer.appendChild(grid); } // For 'podcast' we reuse album card style if (type === 'podcast') { // For 'podcast' we reuse album card style results.forEach(item => { grid.innerHTML += renderAlbumCard(item); }); } else if (type === 'track') { results.forEach(track => { grid.innerHTML += renderTrackCard(track); }); } else if (type === 'album') { results.forEach(album => { grid.innerHTML += renderAlbumCard(album); }); } else if (type === 'artist') { results.forEach(artist => { grid.innerHTML += renderArtistCard(artist); }); } // Always append Load More button at the very end if (persistentLoadMoreBtn) { resultsContainer.appendChild(persistentLoadMoreBtn); } // Attach click listeners if (type === 'track') { grid.querySelectorAll('.track-item').forEach(el => { // Main card click (Play) el.addEventListener('click', (e) => { const trackId = String(el.dataset.id); const track = results.find(t => String(t.id) === trackId); console.log('Track card clicked, ID:', trackId, 'Found:', track?.name); if (track) { playTrack(track); showToast(`Playing "${track.name}"`); } }); // Queue button click const queueBtn = el.querySelector('.queue-btn'); if (queueBtn) { queueBtn.addEventListener('click', (e) => { e.stopPropagation(); const trackId = String(el.dataset.id); const track = results.find(t => String(t.id) === trackId); if (track) addToQueue(track); }); } }); // Fetch features regarding DJ Mode if (state.djMode) { fetchAudioFeaturesForTracks(results); } } else if (type === 'album') { // Album cards - open album modal grid.querySelectorAll('.album-card').forEach(el => { el.addEventListener('click', () => { console.log('Album card clicked, ID:', el.dataset.id); openAlbum(el.dataset.id); }); }); } else if (type === 'podcast') { // Podcast cards - open podcast episodes (not album modal) grid.querySelectorAll('.album-card').forEach(el => { el.addEventListener('click', () => { console.log('Podcast card clicked, ID:', el.dataset.id); openPodcastEpisodes(el.dataset.id); }); }); } else if (type === 'artist') { grid.querySelectorAll('.artist-item').forEach((el, i) => { el.addEventListener('click', () => openArtist(results[i].id)); }); } } // Add track to queue (called from Queue button click) function addToQueue(track) { if (!track) return; state.queue.push(track); updateQueueUI(); showToast(`Added "${track.name}" to queue`); } const downloadModal = $('#download-modal'); const downloadTrackName = $('#download-track-name'); const downloadFormat = $('#download-format'); const downloadCancelBtn = $('#download-cancel-btn'); const downloadConfirmBtn = $('#download-confirm-btn'); const downloadAllBtn = $('#download-all-btn'); // New button let trackToDownload = null; let isBatchDownload = false; // Flag for batch mode // ... functions ... // ========== DOWNLOAD LOGIC ========== window.openDownloadModal = function(trackJson) { const track = JSON.parse(decodeURIComponent(trackJson)); trackToDownload = track; isBatchDownload = false; // Check if we are coming from detailed view (Album/Playlist) if (!detailView.classList.contains('hidden')) { state.pendingAlbumReopen = true; } downloadTrackName.textContent = `${track.name} - ${track.artists}`; downloadModal.classList.remove('hidden'); }; if (downloadAllBtn) { downloadAllBtn.addEventListener('click', () => { if (state.detailTracks.length === 0) return; isBatchDownload = true; trackToDownload = null; // Track previous view if (!detailView.classList.contains('hidden')) { state.pendingAlbumReopen = true; } // Get album/playlist name const name = $('.detail-name').textContent; downloadTrackName.textContent = `All tracks from "${name}" (ZIP)`; downloadModal.classList.remove('hidden'); }); } // Download current playing track buttons const downloadCurrentBtn = $('#download-current-btn'); const fsDownloadBtn = $('#fs-download-btn'); function downloadCurrentTrack() { if (state.currentIndex < 0 || !state.queue[state.currentIndex]) { showToast('No track playing'); return; } const track = state.queue[state.currentIndex]; trackToDownload = track; isBatchDownload = false; downloadTrackName.textContent = `${track.name} - ${track.artists}`; // Filter format options based on track source updateDownloadFormatOptions(track); downloadModal.classList.remove('hidden'); } // Update download format options based on track source quality function updateDownloadFormatOptions(track) { const source = track?.source || ''; const formatSelect = $('#download-format'); const hiresGroup = $('#hires-formats'); const sourceHint = $('#download-source-hint'); // Categorize sources const isHiResSource = source === 'dab' || source === 'qobuz'; const isHiFiSource = source === 'deezer' || source === 'jamendo' || source === 'tidal'; const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || source === 'import' || source === 'archive' || source === 'phish' || source === 'soundcloud' || source === 'bandcamp'; // Re-enable all options first formatSelect.querySelectorAll('option, optgroup').forEach(el => { el.disabled = false; el.style.display = ''; }); // Hide/show hint if (sourceHint) { sourceHint.classList.add('hidden'); sourceHint.textContent = ''; } if (isLossySource) { // Lossy source: only MP3 available formatSelect.querySelectorAll('option').forEach(opt => { if (opt.dataset.minQuality !== 'lossy') { opt.disabled = true; opt.style.display = 'none'; } }); // Hide optgroups for lossless formatSelect.querySelectorAll('optgroup').forEach(grp => { if (grp.label !== 'Lossy') { grp.style.display = 'none'; } }); formatSelect.value = 'mp3'; if (sourceHint) { sourceHint.textContent = `âš ī¸ Source is ${source || 'external'} - only MP3 available`; sourceHint.classList.remove('hidden'); } } else if (isHiFiSource && !isHiResSource) { // HiFi source (16-bit lossless): hide 24-bit options if (hiresGroup) hiresGroup.style.display = 'none'; formatSelect.querySelectorAll('option[data-min-quality="hires"]').forEach(opt => { opt.disabled = true; opt.style.display = 'none'; }); formatSelect.value = 'flac'; } else if (isHiResSource) { // Hi-Res source: show 24-bit only if Hi-Res mode is enabled if (!state.hiResMode) { if (hiresGroup) hiresGroup.style.display = 'none'; formatSelect.querySelectorAll('option[data-min-quality="hires"]').forEach(opt => { opt.disabled = true; opt.style.display = 'none'; }); formatSelect.value = 'flac'; if (sourceHint) { sourceHint.textContent = '💡 Enable Hi-Res mode for 24-bit options'; sourceHint.classList.remove('hidden'); } } else { // All options available formatSelect.value = 'flac_24'; } } else { // Unknown source: default to 16-bit lossless, show 24-bit only if Hi-Res mode if (!state.hiResMode) { if (hiresGroup) hiresGroup.style.display = 'none'; formatSelect.querySelectorAll('option[data-min-quality="hires"]').forEach(opt => { opt.disabled = true; opt.style.display = 'none'; }); } formatSelect.value = 'flac'; } } if (downloadCurrentBtn) { downloadCurrentBtn.addEventListener('click', downloadCurrentTrack); } if (fsDownloadBtn) { fsDownloadBtn.addEventListener('click', downloadCurrentTrack); } function closeDownloadModal() { downloadModal.classList.add('hidden'); trackToDownload = null; isBatchDownload = false; // Restore Album/Playlist view if it was active if (state.pendingAlbumReopen) { detailView.classList.remove('hidden'); state.pendingAlbumReopen = false; // Also ensure Results are hidden if we are in detail view resultsSection.classList.add('hidden'); } } downloadCancelBtn.addEventListener('click', closeDownloadModal); downloadConfirmBtn.addEventListener('click', async () => { const format = downloadFormat.value; const track = trackToDownload; // Capture before closing modal clears it const isBatch = isBatchDownload; closeDownloadModal(); if (isBatch) { // Batch Download Logic const tracks = state.detailTracks; // ... (rest of batch logic) const name = $('.detail-name').textContent || 'Batch Download'; const artist = $('.detail-artist').textContent; const albumName = artist ? `${artist} - ${name}` : name; showLoading(`Preparing ZIP for "${albumName}" (${tracks.length} tracks)...`); try { const response = await fetch('/api/download-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: tracks.map(t => t.isrc || t.id), names: tracks.map(t => t.name), artists: tracks.map(t => t.artists), album_name: albumName, format: format }) }); if (!response.ok) throw new Error('Batch download failed'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `${albumName}.zip`.replace(/[\\/:"*?<>|]/g, "_"); document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); hideLoading(); showToast('Batch download started!'); } catch (error) { console.error('Batch download error:', error); hideLoading(); showError('Failed to create ZIP. Please try again.'); } return; } if (!track) return; // Single Track Logic using captured track variable showLoading(`Downloading "${track.name}" as ${format.toUpperCase()}...`); try { const query = `${track.name} ${track.artists}`; const isrc = track.isrc || track.id; const filename = `${track.name} - ${track.artists}.${format === 'alac' ? 'm4a' : format}`.replace(/[\\/:"*?<>|]/g, "_"); const response = await fetch(`/api/download/${isrc}?q=${encodeURIComponent(query)}&format=${format}&filename=${encodeURIComponent(filename)}`); if (!response.ok) throw new Error('Download failed'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); hideLoading(); showToast('Download started!'); } catch (error) { console.error('Download error:', error); hideLoading(); showError('Failed to download track.'); } }); function renderTrackCard(track) { const year = track.release_date ? track.release_date.slice(0, 4) : ''; // Use horizontal list item layout return `
${escapeHtml(track.name)}
${escapeHtml(track.name)}
${escapeHtml(track.artists)}
${track.duration_ms ? formatTime(track.duration_ms / 1000) : (track.duration && track.duration.toString().includes(':') ? track.duration : formatTime(track.duration))}
`; } // ... (keep renderAlbumCard and renderArtistCard as is) ... function renderAlbumCard(album) { const year = (album.release_date && album.release_date.length >= 4) ? album.release_date.slice(0, 4) : ''; const trackCount = album.total_tracks ? `${album.total_tracks} tracks` : ''; // Check for HiRes quality (if available from API) const isHiRes = album.audio_quality?.isHiRes || album.is_hires || false; const hiResBadge = isHiRes ? 'HI-RES' : ''; return `
${escapeHtml(album.name)} ${hiResBadge}

${escapeHtml(album.name)}

${escapeHtml(album.artists)}

${trackCount} ${year}
`; } function renderArtistCard(artist) { const followers = artist.followers ? `${(artist.followers / 1000).toFixed(0)}K followers` : ''; return `
Artist

${escapeHtml(artist.name)}

${artist.genres?.slice(0, 2).join(', ') || 'Artist'}

${followers}

`; } async function openAlbum(albumId) { // Intercept setlists to open special modal if (albumId.startsWith('setlist_')) { openSetlistModal(albumId); return; } showLoading('Loading album...'); try { const response = await fetch(`/api/album/${albumId}`); const album = await response.json(); if (!response.ok) throw new Error(album.detail); hideLoading(); console.log('Opening album modal for:', album.name, album); showAlbumModal(album); } catch (error) { console.error('Failed to load album:', error); showError('Failed to load album'); } } // Open podcast episodes in detail view (not album modal) async function openPodcastEpisodes(podcastId) { showLoading('Loading podcast episodes...'); try { const response = await fetch(`/api/album/${podcastId}`); const podcast = await response.json(); if (!response.ok) throw new Error(podcast.detail); hideLoading(); console.log('Opening podcast episodes:', podcast.name, podcast); // Use detail view for podcasts (allows clicking episodes for info modal) showDetailView(podcast, podcast.tracks || []); } catch (error) { console.error('Failed to load podcast:', error); showError('Failed to load podcast'); } } // ========== SETLIST MODAL ========== const setlistModal = $('#setlist-modal'); const setlistCloseBtn = $('#setlist-close-btn'); const setlistInfo = $('#setlist-info'); const setlistTracks = $('#setlist-tracks'); const setlistPlayBtn = $('#setlist-play-btn'); let currentSetlist = null; if (setlistCloseBtn) { setlistCloseBtn.addEventListener('click', () => { setlistModal.classList.add('hidden'); }); } if (setlistPlayBtn) { setlistPlayBtn.addEventListener('click', () => { if (currentSetlist) { setlistModal.classList.add('hidden'); // Check if we have a direct audio source URL or need to search if (currentSetlist.audio_url) { // Direct import (Phish.in) - use performSearch which handles URLs performSearch(currentSetlist.audio_url); } else if (currentSetlist.audio_search) { // Search Archive.org (Artist Date) performSearch(currentSetlist.audio_search); } else { showError("No audio source found for this setlist."); } } }); } async function openSetlistModal(setlistId) { // Get modal elements fresh each time const modal = document.getElementById('setlist-modal'); const infoEl = document.getElementById('setlist-info'); const tracksEl = document.getElementById('setlist-tracks'); const playBtn = document.getElementById('setlist-play-btn'); if (!modal) { showError("Setlist modal not available"); return; } showLoading('Fetching setlist...'); try { // Use existing endpoint which returns formatted setlist const response = await fetch(`/api/album/${setlistId}`); const setlist = await response.json(); if (!response.ok) throw new Error(setlist.detail); currentSetlist = setlist; hideLoading(); // Render Modal Content infoEl.innerHTML = `

${escapeHtml(setlist.artists)}

${escapeHtml(setlist.venue)}

${setlist.date || setlist.release_date}

${setlist.city}

`; tracksEl.innerHTML = setlist.tracks.map((track, i) => `
${i + 1}
${escapeHtml(track.name)} ${track.set_name ? `${track.set_name}` : ''}
${track.info ? `

${escapeHtml(track.info)}

` : ''} ${track.cover_info ? `

(Cover of ${escapeHtml(track.cover_info)})

` : ''}
`).join(''); // Show audio source button label if (setlist.audio_source === 'phish.in') { playBtn.textContent = "🎧 Listen on Phish.in"; } else { playBtn.textContent = "🎧 Search on Archive.org"; } modal.classList.remove('hidden'); } catch (error) { console.error(error); showError('Failed to load setlist'); } } // ========== ALBUM DETAILS MODAL ========== const albumModal = $('#album-modal'); const albumModalClose = $('#album-modal-close'); const albumModalOverlay = albumModal?.querySelector('.album-modal-overlay'); let currentAlbumData = null; // Close modal if (albumModalClose) { albumModalClose.addEventListener('click', () => { albumModal.classList.add('hidden'); }); } // Close on overlay click if (albumModalOverlay) { albumModalOverlay.addEventListener('click', () => { albumModal.classList.add('hidden'); }); } // Tab switching const albumTabs = albumModal?.querySelectorAll('.album-tab'); albumTabs?.forEach(tab => { tab.addEventListener('click', () => { albumTabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); const tabName = tab.dataset.tab; if (tabName === 'tracks') { $('#album-modal-tracks')?.classList.remove('hidden'); $('#album-modal-info-tab')?.classList.add('hidden'); } else { $('#album-modal-tracks')?.classList.add('hidden'); $('#album-modal-info-tab')?.classList.remove('hidden'); } }); }); // Action buttons $('#album-play-btn')?.addEventListener('click', () => { if (currentAlbumData?.tracks?.length) { state.queue = [...currentAlbumData.tracks]; state.currentIndex = 0; updateQueueUI(); loadTrack(state.queue[0]); albumModal.classList.add('hidden'); showToast(`Playing "${currentAlbumData.name}"`); } }); $('#album-queue-btn')?.addEventListener('click', () => { if (currentAlbumData?.tracks?.length) { state.queue.push(...currentAlbumData.tracks); updateQueueUI(); albumModal.classList.add('hidden'); showToast(`Added ${currentAlbumData.tracks.length} tracks to queue`); } }); $('#album-download-btn')?.addEventListener('click', () => { if (currentAlbumData) { isBatchDownload = true; trackToDownload = null; state.detailTracks = currentAlbumData.tracks; downloadTrackName.textContent = `${currentAlbumData.name} (${currentAlbumData.tracks?.length || 0} tracks)`; downloadModal.classList.remove('hidden'); albumModal.classList.add('hidden'); } }); $('#album-playlist-btn')?.addEventListener('click', () => { if (currentAlbumData?.tracks?.length) { // Open add to playlist modal with all album tracks if (typeof openAddToPlaylistModal === 'function') { openAddToPlaylistModal(currentAlbumData.tracks, currentAlbumData.name); } else { showToast('Playlist feature coming soon'); } } }); function showAlbumModal(album) { if (!albumModal) return; currentAlbumData = album; const tracks = album.tracks || []; // Populate modal data $('#album-modal-art').src = album.album_art || album.image || '/static/icon.svg'; $('#album-modal-title').textContent = album.name || 'Unknown Album'; $('#album-modal-artist').textContent = album.artists || 'Unknown Artist'; // Metadata pills const date = album.release_date || ''; const genre = album.genres?.[0] || album.genre || ''; const trackCount = tracks.length || album.total_tracks || 0; const totalDuration = tracks.reduce((sum, t) => sum + (parseDuration(t.duration) || 0), 0); const durationMins = Math.round(totalDuration / 60); $('#album-modal-date').textContent = `📅 ${date || 'Unknown'}`; // Genre removed $('#album-modal-trackcount').textContent = `đŸŽĩ ${trackCount} tracks`; $('#album-modal-duration').textContent = `âąī¸ ${durationMins || '??'} min`; // Quality badge const format = album.format || (state.hifiMode ? 'FLAC' : 'MP3'); const bitDepth = album.audio_quality?.maximumBitDepth || 16; const sampleRate = album.audio_quality?.maximumSamplingRate || 44.1; $('#album-modal-quality').textContent = `đŸŽĩ ${format} â€ĸ ${bitDepth}bit / ${sampleRate}kHz`; // Render track list const tracksContainer = $('#album-modal-tracks'); tracksContainer.innerHTML = tracks.map((track, i) => `
${i + 1}.

${escapeHtml(track.name)}

${track.duration || '--:--'}
`).join(''); // Track click handlers tracksContainer.querySelectorAll('.album-track-play').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.index); state.queue = [...tracks]; state.currentIndex = idx; updateQueueUI(); loadTrack(tracks[idx]); albumModal.classList.add('hidden'); }); }); tracksContainer.querySelectorAll('[data-action="queue"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.index); state.queue.push(tracks[idx]); updateQueueUI(); showToast(`Added "${tracks[idx].name}" to queue`); }); }); // Playlist button handler tracksContainer.querySelectorAll('.album-track-playlist').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.index); const track = tracks[idx]; // Open add to playlist modal if (typeof openAddToPlaylistModal === 'function') { openAddToPlaylistModal(track); } else { showToast('Playlist feature coming soon'); } }); }); tracksContainer.querySelectorAll('[data-action="download"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.index); // Hide album modal first so download modal appears on top albumModal.classList.add('hidden'); // Store album data so we can reopen after download closes state.pendingAlbumReopen = { album, tracks }; openDownloadModal(encodeURIComponent(JSON.stringify(tracks[idx]))); }); }); // Album Info tab $('#album-modal-description').textContent = album.description || `${album.name} by ${album.artists}. Released ${date}. ${trackCount} tracks.`; // Reset to tracks tab albumTabs?.forEach(t => t.classList.remove('active')); albumModal.querySelector('[data-tab="tracks"]')?.classList.add('active'); $('#album-modal-tracks')?.classList.remove('hidden'); $('#album-modal-info-tab')?.classList.add('hidden'); // Show modal albumModal.classList.remove('hidden'); } // Helper to parse duration string to seconds function parseDuration(dur) { if (!dur) return 0; const parts = dur.split(':').map(Number); if (parts.length === 2) return parts[0] * 60 + parts[1]; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; return 0; } async function openArtist(artistId) { showLoading('Loading artist...'); try { const response = await fetch(`/api/artist/${artistId}`); const artist = await response.json(); if (!response.ok) throw new Error(artist.detail); hideLoading(); showDetailView(artist, artist.tracks); } catch (error) { showError('Failed to load artist'); } } // Updated showDetailView to handle downloads function showDetailView(item, tracks) { state.detailTracks = tracks || []; const isUserPlaylist = item.is_user_playlist || false; // Render info section const isArtist = item.type === 'artist'; const image = item.album_art || item.image || '/static/icon.svg'; const subtitle = item.artists || item.owner || (item.genres?.slice(0, 2).join(', ')) || ''; const stats = item.total_tracks ? `${item.total_tracks} tracks` : item.followers ? `${(item.followers / 1000).toFixed(0)}K followers` : ''; detailInfo.innerHTML = ` Cover

${escapeHtml(item.name)}

${escapeHtml(subtitle)}

${stats}

`; // Render tracks with download button (and delete for user playlists) detailTracks.innerHTML = tracks.map((t, i) => `
Art

${escapeHtml(t.name)}

${escapeHtml(t.artists)}

${renderDJBadgeForTrack(t)} ${t.duration} ${t.source === 'podcast' ? ` ` : ''} ${isUserPlaylist ? ` ` : ''}
`).join(''); // Show detail view detailView.classList.remove('hidden'); resultsSection.classList.add('hidden'); if (state.djMode && tracks.length > 0) { fetchAudioFeaturesForTracks(tracks); } } // ========== DOWNLOAD LOGIC ========== window.openDownloadModal = function(trackJson) { const track = JSON.parse(decodeURIComponent(trackJson)); trackToDownload = track; downloadTrackName.textContent = `${track.name} - ${track.artists}`; downloadModal.classList.remove('hidden'); }; function closeDownloadModal() { downloadModal.classList.add('hidden'); trackToDownload = null; } downloadCancelBtn.addEventListener('click', closeDownloadModal); downloadConfirmBtn.addEventListener('click', async () => { if (!trackToDownload) return; const format = downloadFormat.value; const track = trackToDownload; closeDownloadModal(); // Show non-blocking notification or toast could be better, but we'll use loading for now // or just let it happen in background. Let's show a loading indicator. showLoading(`Downloading "${track.name}" as ${format.toUpperCase()}...`); try { const query = `${track.name} ${track.artists}`; const isrc = track.isrc || track.id; // Fallback to ID if ISRC missing // Construct filename const filename = `${track.artists} - ${track.name}.${format === 'alac' ? 'm4a' : format}`.replace(/[\\/:"*?<>|]/g, "_"); const response = await fetch(`/api/download/${isrc}?q=${encodeURIComponent(query)}&format=${format}&filename=${encodeURIComponent(filename)}`); if (!response.ok) throw new Error('Download failed'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); hideLoading(); } catch (error) { console.error('Download error:', error); showError('Failed to download track. Please try again.'); } }); // Configure Save to Drive button $('#download-drive-btn').addEventListener('click', async () => { if (!trackToDownload) return; // Ensure signed in logic if (!googleAccessToken) { await signInWithGoogle(); if (!googleAccessToken) return; // User cancelled or failed } const format = downloadFormat.value; const track = trackToDownload; closeDownloadModal(); showLoading(`Saving "${track.name}" to Google Drive (as ${format.toUpperCase()})...`); try { // 1. Get Folder ID const folderId = await findOrCreateFreedifyFolder(); if (!folderId) throw new Error('Could not find or create "Freedify" folder'); // 2. Call Backend to Upload const query = `${track.name} ${track.artists}`; const isrc = track.isrc || track.id; // Construct filename const filename = `${track.artists} - ${track.name}.${format === 'alac' ? 'm4a' : format}`.replace(/[\\/:"*?<>|]/g, "_"); const response = await fetch('/api/drive/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isrc: isrc, access_token: googleAccessToken, format: format, folder_id: folderId, filename: filename, q: query }) }); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Upload failed'); } const result = await response.json(); hideLoading(); showToast(`✅ Saved to Drive: ${result.name}`); } catch (error) { console.error('Drive save error:', error); hideLoading(); showError(`Failed to save to Drive: ${error.message}`); } }); // Close modal on outside click downloadModal.addEventListener('click', (e) => { if (e.target === downloadModal) closeDownloadModal(); }); // ========== KEYBOARD SHORTCUTS ========== document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; switch (e.code) { case 'Space': e.preventDefault(); togglePlay(); break; case 'ArrowRight': if (e.shiftKey) getActivePlayer().currentTime += 10; else playNext(); break; case 'ArrowLeft': if (e.shiftKey) getActivePlayer().currentTime -= 10; else playPrevious(); break; case 'Escape': if (!downloadModal.classList.contains('hidden')) { closeDownloadModal(); } else if (!detailView.classList.contains('hidden')) { hideDetailView(); } break; } }); // ========== SERVICE WORKER ========== if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(console.error); } // Initial state showEmptyState(); // Duplicate render functions removed - using definitions at line 753 // ========== ALBUM / ARTIST / PLAYLIST DETAIL VIEW ========== // Note: openAlbum is defined earlier with setlist modal support async function openArtist(artistId) { showLoading('Loading artist...'); try { const response = await fetch(`/api/artist/${artistId}`); const artist = await response.json(); if (!response.ok) throw new Error(artist.detail); hideLoading(); showDetailView(artist, artist.tracks); } catch (error) { showError('Failed to load artist'); } } function showDetailView(item, tracks) { state.detailTracks = tracks || []; // Render info section const isArtist = item.type === 'artist'; const image = item.album_art || item.image || '/static/icon.svg'; const subtitle = item.artists || item.owner || (item.genres?.slice(0, 2).join(', ')) || ''; const stats = item.total_tracks ? `${item.total_tracks} tracks` : item.followers ? `${(item.followers / 1000).toFixed(0)}K followers` : ''; // Check if this is already a saved playlist (to avoid re-saving) const isSavedPlaylist = item.id && item.id.startsWith('playlist_'); detailInfo.innerHTML = ` Cover

${escapeHtml(item.name)}

${escapeHtml(subtitle)}

${stats}

${!isSavedPlaylist && tracks.length > 0 ? `` : ''}
`; // Add save playlist handler const saveBtn = $('#save-playlist-btn'); if (saveBtn) { saveBtn.addEventListener('click', () => { const name = prompt('Enter playlist name:', item.name || 'My Playlist'); if (name) { createPlaylist(name, state.detailTracks); } }); } // Render tracks detailTracks.innerHTML = tracks.map((t, i) => `
Art

${escapeHtml(t.name)}

${escapeHtml(t.artists)}

${t.duration}
`).join(''); // Add click handlers $$('#detail-tracks .track-item').forEach((el, i) => { el.addEventListener('click', () => { const track = tracks[i]; // Show podcast modal for podcast episodes, otherwise play directly if (track.source === 'podcast' && track.description) { showPodcastModal(track); } else { playTrack(track); } }); }); // Show detail view detailView.classList.remove('hidden'); resultsSection.classList.add('hidden'); } function hideDetailView() { detailView.classList.add('hidden'); resultsSection.classList.remove('hidden'); } backBtn.addEventListener('click', hideDetailView); queueAllBtn.addEventListener('click', () => { if (state.detailTracks.length === 0) return; // Add all tracks to queue state.detailTracks.forEach(track => { if (!state.queue.find(t => t.id === track.id)) { state.queue.push(track); } }); updateQueueUI(); // Start playing first if nothing is playing if (state.currentIndex === -1 && state.queue.length > 0) { state.currentIndex = 0; loadTrack(state.queue[0]); } hideDetailView(); }); // Shuffle & Play button shuffleBtn.addEventListener('click', () => { if (state.detailTracks.length === 0) return; // Copy and shuffle tracks using Fisher-Yates const shuffled = [...state.detailTracks]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } // Clear queue and add shuffled tracks state.queue = []; shuffled.forEach(track => state.queue.push(track)); // Start playing first shuffled track state.currentIndex = 0; updateQueueUI(); loadTrack(state.queue[0]); hideDetailView(); }); // ========== PLAYBACK ========== function playTrack(track) { if (!track || !track.id) { console.error("playTrack called with invalid track:", track); return; } // Add to queue if not already there const existingIndex = state.queue.findIndex(t => t && t.id === track.id); if (existingIndex === -1) { state.queue.push(track); state.currentIndex = state.queue.length - 1; } else { state.currentIndex = existingIndex; } updateQueueUI(); // Check if this track is already preloaded and ready - use it directly! if (preloadedTrackId === track.id && preloadedReady && preloadedPlayer) { console.log('Using preloaded track:', track.name); // Reset preload state preloadedTrackId = null; preloadedReady = false; // Update all UI updatePlayerUI(); updateFullscreenUI(track); // Switch to preloaded player performGaplessSwitch(); // Update format badge updateFormatBadge(getActivePlayer().src); // Preload the next one setTimeout(preloadNextTrack, 500); return; } loadTrack(track); } // ========== ALBUM ART COLOR EXTRACTION ========== function extractDominantColor(imageUrl) { // Create an image to load the album art const img = new Image(); img.crossOrigin = 'anonymous'; // Allow cross-origin images img.onload = () => { try { // Create a small canvas for sampling const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const sampleSize = 10; // Sample at 10x10 for performance canvas.width = sampleSize; canvas.height = sampleSize; // Draw the scaled-down image ctx.drawImage(img, 0, 0, sampleSize, sampleSize); // Get pixel data const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize); const pixels = imageData.data; // Calculate average color (excluding very dark/light pixels) let r = 0, g = 0, b = 0, count = 0; for (let i = 0; i < pixels.length; i += 4) { const pr = pixels[i], pg = pixels[i + 1], pb = pixels[i + 2]; const brightness = (pr + pg + pb) / 3; // Skip very dark or very light pixels if (brightness > 30 && brightness < 220) { r += pr; g += pg; b += pb; count++; } } if (count > 0) { r = Math.round(r / count); g = Math.round(g / count); b = Math.round(b / count); // Apply to player section as a subtle gradient const playerSection = $('.player-section'); if (playerSection) { playerSection.style.background = `linear-gradient(180deg, rgba(${r}, ${g}, ${b}, 0.15) 0%, var(--bg-primary) 100%)`; } } } catch (e) { // Canvas tainted or other error - silently ignore console.log('Could not extract color from album art'); } }; img.onerror = () => { // Reset to default if image fails const playerSection = $('.player-section'); if (playerSection) { playerSection.style.background = ''; } }; img.src = imageUrl; } function updatePlayerUI() { if (state.currentIndex < 0 || !state.queue[state.currentIndex]) return; const track = state.queue[state.currentIndex]; // Basic Info playerBar.classList.remove('hidden'); playerTitle.textContent = track.name; playerArtist.textContent = track.artists || '-'; // Update visualizer info if active (Immersive Mode support) if (visualizerActive && typeof showVisualizerInfoBriefly === 'function') { showVisualizerInfoBriefly(); } // Album name (clickable to open album) if (playerAlbum) { playerAlbum.textContent = track.album || '-'; playerAlbum.dataset.albumId = track.album_id || ''; playerAlbum.dataset.albumName = track.album || ''; } // Release year only (extract from YYYY-MM-DD) if (playerYear) { const year = track.release_date ? track.release_date.slice(0, 4) : ''; playerYear.textContent = year ? `(${year})` : ''; } playerArt.src = track.album_art || '/static/icon.svg'; // Extract dominant color for player background if (track.album_art) { extractDominantColor(track.album_art); } // DJ Mode Info const playerDJInfo = $('#player-dj-info'); if (state.djMode && playerDJInfo) { // Use embedded audio_features for local tracks, cache for others const isLocal = track.id?.startsWith('local_'); const feat = isLocal ? track.audio_features : state.audioFeaturesCache[track.id]; if (feat) { const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; playerDJInfo.innerHTML = `
${feat.bpm} BPM ${feat.camelot}
`; playerDJInfo.classList.remove('hidden'); } else { // If active track is missing features, fetch them (debounce/check logic needed to avoid loop?) // fetchAudioFeaturesForTracks([track]); // Avoiding loop, fetch should handle it playerDJInfo.innerHTML = '
'; playerDJInfo.classList.remove('hidden'); } } else if (playerDJInfo) { playerDJInfo.classList.add('hidden'); } // Update Mini Player if (pipWindow) updateMiniPlayer(); } // Update audio format badge (FLAC/MP3) async function updateFormatBadge(audioSrc) { const badge = document.getElementById('audio-format-badge'); if (!badge) return; // For local files, show nothing if (!audioSrc || audioSrc.startsWith('blob:') || audioSrc.startsWith('file:')) { badge.classList.add('hidden'); return; } // Get current track source to determine actual quality const currentTrack = state.queue[state.currentIndex]; const source = currentTrack?.source || ''; // Determine format based on source const isHiResSource = source === 'dab' || source === 'qobuz'; const isHiFiSource = source === 'deezer' || source === 'jamendo'; const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || source === 'import'; badge.classList.remove('hidden', 'mp3', 'flac', 'hi-res'); if (isHiResSource && state.hiResMode) { // Hi-Res 24-bit (Dab/Qobuz with Hi-Res mode) badge.classList.add('flac', 'hi-res'); badge.textContent = 'Hi-Res'; } else if (isHiResSource || isHiFiSource) { // HiFi 16-bit FLAC (Deezer, Jamendo, or Dab without Hi-Res mode) badge.classList.add('flac'); badge.textContent = 'FLAC'; } else if (isLossySource) { // Lossy MP3/AAC (YouTube, podcasts, imports) badge.classList.add('mp3'); badge.textContent = 'MP3'; } else { // Unknown source - default based on preference badge.classList.add('flac'); if (state.hiResMode) { badge.classList.add('hi-res'); } badge.textContent = 'FLAC'; } // Also update the HiFi button in header if (typeof updateHifiButtonUI === 'function') { updateHifiButtonUI(); } } // Player artist/album click handlers for discovery if (playerArtist) { playerArtist.addEventListener('click', () => { const artistName = playerArtist.textContent; if (artistName && artistName !== '-') { state.searchType = 'artist'; document.querySelectorAll('.type-btn, .type-btn-menu').forEach(b => b.classList.remove('active')); const artistBtn = document.querySelector('[data-type="artist"]'); if (artistBtn) artistBtn.classList.add('active'); searchInput.value = artistName; performSearch(artistName); } }); } if (playerAlbum) { playerAlbum.addEventListener('click', () => { const albumId = playerAlbum.dataset.albumId; if (albumId) { openAlbum(albumId); } }); } // Track load state to prevent duplicates let loadInProgress = false; let loadTimeoutId = null; async function loadTrack(track) { // Prevent duplicate loads if (loadInProgress) { console.log('Load already in progress, skipping duplicate load for:', track.name); return; } loadInProgress = true; showLoading(`Loading "${track.name}"...`); state.scrobbledCurrent = false; // Reset scrobble status playerBar.classList.remove('hidden'); // Reset preload state on direct track load preloadedTrackId = null; preloadedPlayer = null; if (crossfadeTimeout) { clearTimeout(crossfadeTimeout); crossfadeTimeout = null; } // Clear any existing load timeout if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null; } updatePlayerUI(); updateQueueUI(); updateFullscreenUI(track); // Sync FS // Get the active player const player = getActivePlayer(); const playerGain = activePlayer === 1 ? gainNode1 : gainNode2; // Make sure active player gain is at 1 if (playerGain) playerGain.gain.value = 1; // For ListenBrainz tracks, try to enrich with album art from search if (track.source === 'listenbrainz' && track.album_art === '/static/icon.svg') { try { const searchQuery = track.artists + ' ' + track.name; const searchRes = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}&type=track&limit=1`); const searchData = await searchRes.json(); if (searchData.results && searchData.results.length > 0) { const foundTrack = searchData.results[0]; if (foundTrack.album_art && foundTrack.album_art !== '/static/icon.svg') { track.album_art = foundTrack.album_art; console.log('Enriched LB track with album art:', foundTrack.album_art); updatePlayerUI(); // Refresh the player bar with new art updateFullscreenUI(track); } } } catch (e) { console.log('Could not enrich LB track art:', e); } } // Play if (track.is_local && track.src) { player.src = track.src; } else { const hiresParam = state.hiResMode ? '&hires=true' : '&hires=false'; player.src = `/api/stream/${track.isrc || track.id}?q=${encodeURIComponent(track.name + ' ' + track.artists)}${hiresParam}`; } try { player.load(); await new Promise((resolve, reject) => { const cleanup = () => { player.oncanplay = null; player.onerror = null; if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null; } }; player.oncanplay = () => { cleanup(); resolve(); }; player.onerror = () => { cleanup(); reject(new Error('Failed to load audio')); }; loadTimeoutId = setTimeout(() => { cleanup(); reject(new Error('Timeout loading audio')); }, 120000); }); hideLoading(); player.play(); state.isPlaying = true; updatePlayButton(); updateMediaSession(track); // Detect audio format and update badge updateFormatBadge(player.src); } catch (error) { console.error('Playback error:', error); showError('Failed to load track. Please try again.'); } finally { loadInProgress = false; } } // Player controls playBtn.addEventListener('click', togglePlay); prevBtn.addEventListener('click', playPrevious); if (miniPlayerBtn) miniPlayerBtn.addEventListener('click', toggleMiniPlayer); nextBtn.addEventListener('click', playNext); // Shuffle current queue shuffleQueueBtn.addEventListener('click', () => { if (state.queue.length <= 1) return; // Get currently playing track const currentTrack = state.queue[state.currentIndex]; // Remove current track from queue temporarily const otherTracks = state.queue.filter((_, i) => i !== state.currentIndex); // Shuffle the other tracks using Fisher-Yates for (let i = otherTracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [otherTracks[i], otherTracks[j]] = [otherTracks[j], otherTracks[i]]; } // Put current track at front, add shuffled tracks after state.queue = [currentTrack, ...otherTracks]; state.currentIndex = 0; updateQueueUI(); // Visual feedback shuffleQueueBtn.style.transform = 'scale(1.2)'; setTimeout(() => shuffleQueueBtn.style.transform = '', 200); }); function togglePlay() { const player = getActivePlayer(); // Check if player has source logic (for refresh case) if (!player.src && state.queue.length > 0 && state.currentIndex >= 0) { // Queue exists but nothing loaded yet (refresh case) loadTrack(state.queue[state.currentIndex]); return; } if (player.paused) { player.play().catch(e => { console.warn('Play failed, trying to reload:', e); if (state.queue[state.currentIndex]) { loadTrack(state.queue[state.currentIndex]); } }); } else { player.pause(); } } function playNext() { const currentTrack = state.queue[state.currentIndex]; const player = getActivePlayer(); // Podcast: seek +15s instead of next track if (currentTrack && currentTrack.source === 'podcast') { player.currentTime = Math.min(player.duration || 0, player.currentTime + 15); return; } if (state.currentIndex < state.queue.length - 1) { state.currentIndex++; // Try to use preloaded player (no loading screen) - ONLY if ready if (preloadedReady && preloadedPlayer && preloadedTrackId === state.queue[state.currentIndex]?.id) { console.log('playNext: Using preloaded player for:', state.queue[state.currentIndex].name); preloadedTrackId = null; preloadedReady = false; updatePlayerUI(); updateQueueUI(); updateFullscreenUI(state.queue[state.currentIndex]); performGaplessSwitch(); updateFormatBadge(getActivePlayer().src); updateMediaSession(state.queue[state.currentIndex]); setTimeout(preloadNextTrack, 500); } else { loadTrack(state.queue[state.currentIndex]); } } } function playPrevious() { const currentTrack = state.queue[state.currentIndex]; const player = getActivePlayer(); // Podcast: seek -15s instead of prev track if (currentTrack && currentTrack.source === 'podcast') { player.currentTime = Math.max(0, player.currentTime - 15); return; } if (player.currentTime > 3) { player.currentTime = 0; } else if (state.currentIndex > 0) { state.currentIndex--; loadTrack(state.queue[state.currentIndex]); } } // Shared event handlers for both audio players function handlePlay() { state.isPlaying = true; updatePlayButton(); const track = state.queue[state.currentIndex]; if (track) submitNowPlaying(track); } function handlePause() { // Only update if the active player paused if (this === getActivePlayer()) { state.isPlaying = false; updatePlayButton(); } } function handleProgress() { const player = getActivePlayer(); if (player.duration > 0 && player.buffered.length > 0) { // Check if we have buffered enough to start next download const bufferedEnd = player.buffered.end(player.buffered.length - 1); if (bufferedEnd >= player.duration - 60) { // 60 seconds before end (for long songs) preloadNextTrack(); } } } // Bind events to both players audioPlayer.addEventListener('play', handlePlay); audioPlayer2.addEventListener('play', handlePlay); audioPlayer.addEventListener('pause', handlePause); audioPlayer2.addEventListener('pause', handlePause); audioPlayer.addEventListener('progress', handleProgress); audioPlayer2.addEventListener('progress', handleProgress); // Ended fallback for audioPlayer2 only (audioPlayer uses playNextWithRepeat instead) // Note: audioPlayer's ended handler is added later as playNextWithRepeat audioPlayer2.addEventListener('ended', () => { // Skip if gapless already handled this transition if (crossfadeTimeout) return; if (getActivePlayer() === audioPlayer2) playNext(); }); audioPlayer.addEventListener('timeupdate', handleTimeUpdate); audioPlayer2.addEventListener('timeupdate', handleTimeUpdate); function handleTimeUpdate() { // Update Mini Player Time if (pipWindow) updateMiniPlayer(); const player = getActivePlayer(); if (player.duration) { currentTime.textContent = formatTime(player.currentTime); duration.textContent = formatTime(player.duration); progressBar.value = (player.currentTime / player.duration) * 100; // Sync FS Progress fsCurrentTime.textContent = currentTime.textContent; fsDuration.textContent = duration.textContent; fsProgressBar.value = progressBar.value; // Update CSS variable for gradient fill progressBar.style.setProperty('--value', progressBar.value + '%'); fsProgressBar.style.setProperty('--value', progressBar.value + '%'); // Scrobble Check (50% or 4 mins) if (!state.scrobbledCurrent && state.queue[state.currentIndex]) { if (player.currentTime > 240 || player.currentTime > player.duration / 2) { submitScrobble(state.queue[state.currentIndex]); } } // Time-based preload trigger (1 minute before end - better for long songs) const timeRemaining = player.duration - player.currentTime; if (timeRemaining <= 60 && timeRemaining > 0 && !preloadedTrackId) { preloadNextTrack(); } // Crossfade/Gapless trigger: start transition before track ends // Using 200ms buffer - try to use preloaded player even if not flagged "ready" const crossfadeTime = crossfadeEnabled ? CROSSFADE_DURATION / 1000 : 0.2; // Trigger if preloaded player exists (don't strictly require preloadedReady - streaming can start anyway) if (timeRemaining <= crossfadeTime && timeRemaining > 0 && preloadedPlayer && !crossfadeTimeout) { if (state.currentIndex < state.queue.length - 1) { // Mark that we're handling crossfade crossfadeTimeout = setTimeout(() => { crossfadeTimeout = null; }, crossfadeTime * 1000 + 500); // Advance to next track state.currentIndex++; state.scrobbledCurrent = false; preloadedTrackId = null; preloadedReady = false; // Reset ready flag // Update UI for new track updatePlayerUI(); updateQueueUI(); updateFullscreenUI(state.queue[state.currentIndex]); // Sync fullscreen UI // Perform crossfade or gapless switch if (crossfadeEnabled) { performCrossfade(); } else { performGaplessSwitch(); } // Update format badge updateFormatBadge(getActivePlayer().src); // Preload the next one setTimeout(preloadNextTrack, 500); } } } } progressBar.addEventListener('input', (e) => { const player = getActivePlayer(); if (player.duration && Number.isFinite(player.duration)) { player.currentTime = (e.target.value / 100) * player.duration; e.target.style.setProperty('--value', e.target.value + '%'); if (typeof fsProgressBar !== 'undefined') fsProgressBar.style.setProperty('--value', e.target.value + '%'); } }); function updatePlayButton() { playBtn.textContent = state.isPlaying ? '⏸' : 'â–ļ'; if (typeof updateFSPlayBtn === 'function') updateFSPlayBtn(); } // ========== QUEUE ========== // queueClose and queueClear are defined at top level // ... queueBtn.addEventListener('click', () => { queueSection.classList.toggle('hidden'); }); queueClose.addEventListener('click', () => { queueSection.classList.add('hidden'); }); queueClear.addEventListener('click', () => { state.queue = []; state.currentIndex = -1; updateQueueUI(); }); // Delegated click handler for queue items (handles play, remove, and add-to-playlist) queueContainer.addEventListener('click', (e) => { // Check if clicked on remove button const removeBtn = e.target.closest('.queue-remove-btn'); if (removeBtn) { e.stopPropagation(); const index = parseInt(removeBtn.dataset.index, 10); window.removeFromQueue(index); return; } // Check if clicked on heart button (add to playlist) const heartBtn = e.target.closest('.queue-heart-btn'); if (heartBtn) { e.stopPropagation(); const index = parseInt(heartBtn.dataset.index, 10); const track = state.queue[index]; if (track && window.openAddToPlaylistModal) { window.openAddToPlaylistModal(track); } return; } // Check if clicked on queue item (to play) const queueItem = e.target.closest('.queue-item'); if (queueItem) { const index = parseInt(queueItem.dataset.index, 10); state.currentIndex = index; loadTrack(state.queue[index]); } }); // ========== QUEUE PERSISTENCE ========== function saveQueueToStorage() { try { // Save queue and current index const queueData = { queue: state.queue, currentIndex: state.currentIndex }; localStorage.setItem('freedify_queue', JSON.stringify(queueData)); } catch (e) { console.warn('Could not save queue to storage:', e); } } function loadQueueFromStorage() { try { const saved = localStorage.getItem('freedify_queue'); if (saved) { const queueData = JSON.parse(saved); if (queueData.queue && Array.isArray(queueData.queue) && queueData.queue.length > 0) { state.queue = queueData.queue; state.currentIndex = queueData.currentIndex || 0; updateQueueUI(); // Load the track but don't auto-play if (state.queue[state.currentIndex]) { updatePlayerUI(); } console.log(`Restored queue: ${state.queue.length} tracks`); } } } catch (e) { console.warn('Could not load queue from storage:', e); } } function updateQueueUI() { queueCount.textContent = `(${state.queue.length})`; // Persist queue to localStorage saveQueueToStorage(); if (state.queue.length === 0) { queueContainer.innerHTML = '

Queue is empty

'; return; } queueContainer.innerHTML = state.queue.filter(t => t).map((track, i) => `
Art

${escapeHtml(track.name || 'Unknown')}

${escapeHtml(track.artists || '')}

`).join(''); // Mark currently playing and scroll into view const currentEl = queueContainer.querySelector(`[data-index="${state.currentIndex}"]`); if (currentEl) { currentEl.classList.add('playing'); currentEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } // ========== PRELOADING ========== let preloadedTrackId = null; function preloadNextTrack() { if (state.currentIndex === -1 || state.currentIndex >= state.queue.length - 1) return; const nextTrack = state.queue[state.currentIndex + 1]; if (!nextTrack || nextTrack.id === preloadedTrackId) return; preloadedTrackId = nextTrack.id; preloadedReady = false; // Reset ready flag console.log('Preloading next track into inactive player:', nextTrack.name); const query = `${nextTrack.name} ${nextTrack.artists}`; const hiresParam = state.hiResMode ? '&hires=true' : '&hires=false'; const streamUrl = `/api/stream/${nextTrack.isrc || nextTrack.id}?q=${encodeURIComponent(query)}${hiresParam}`; // Load into the inactive player for gapless transition const inactivePlayer = activePlayer === 1 ? audioPlayer2 : audioPlayer; // Set up canplay listener for faster ready detection // canplay fires when enough data is available to start (faster than canplaythrough) const onReady = () => { preloadedReady = true; console.log('Preloaded track ready (canplay):', nextTrack.name); inactivePlayer.removeEventListener('canplay', onReady); }; inactivePlayer.addEventListener('canplay', onReady); inactivePlayer.src = streamUrl; inactivePlayer.load(); preloadedPlayer = inactivePlayer; console.log('Next track loading into player', activePlayer === 1 ? 2 : 1); } // Get the currently active audio player function getActivePlayer() { return activePlayer === 1 ? audioPlayer : audioPlayer2; } // Get the inactive audio player (for preloading) function getInactivePlayer() { return activePlayer === 1 ? audioPlayer2 : audioPlayer; } // Perform crossfade between players function performCrossfade() { const fadeOutGain = activePlayer === 1 ? gainNode1 : gainNode2; const fadeInGain = activePlayer === 1 ? gainNode2 : gainNode1; const newPlayer = getInactivePlayer(); if (!audioContext || !fadeOutGain || !fadeInGain) return; const now = audioContext.currentTime; const fadeDuration = CROSSFADE_DURATION / 1000; // Start playing the new track newPlayer.play().catch(e => console.error('Crossfade play error:', e)); // Crossfade: fade out current, fade in next fadeOutGain.gain.setValueAtTime(1, now); fadeOutGain.gain.linearRampToValueAtTime(0, now + fadeDuration); fadeInGain.gain.setValueAtTime(0, now); fadeInGain.gain.linearRampToValueAtTime(1, now + fadeDuration); // Switch active player activePlayer = activePlayer === 1 ? 2 : 1; // Pause old player after fade completes setTimeout(() => { const oldPlayer = getInactivePlayer(); oldPlayer.pause(); oldPlayer.currentTime = 0; }, CROSSFADE_DURATION + 100); console.log('Crossfade to player', activePlayer); } // Instant gapless switch (no crossfade) function performGaplessSwitch() { const newPlayer = getInactivePlayer(); const oldPlayer = getActivePlayer(); const fadeOutGain = activePlayer === 1 ? gainNode1 : gainNode2; const fadeInGain = activePlayer === 1 ? gainNode2 : gainNode1; // Ensure gains are set correctly if (fadeOutGain) fadeOutGain.gain.value = 0; if (fadeInGain) fadeInGain.gain.value = 1; // Start new player newPlayer.play().catch(e => console.error('Gapless play error:', e)); // Stop old player immediately oldPlayer.pause(); oldPlayer.currentTime = 0; // Switch active player activePlayer = activePlayer === 1 ? 2 : 1; console.log('Gapless switch to player', activePlayer); } // ========== MEDIA SESSION ========== function updateMediaSession(track) { // DJ Mode Info in Player const playerDJInfo = $('#player-dj-info'); if (state.djMode && playerDJInfo) { const feat = state.audioFeaturesCache[track.id]; if (feat) { const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; playerDJInfo.innerHTML = `
${feat.bpm} BPM ${feat.camelot}
`; playerDJInfo.classList.remove('hidden'); } else { // Try to fetch if missing fetchAudioFeaturesForQueue(); playerDJInfo.classList.add('hidden'); } } else if (playerDJInfo) { playerDJInfo.classList.add('hidden'); } if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title: track.name, artist: track.artists, album: track.album || '', artwork: track.album_art ? [{ src: track.album_art, sizes: '512x512' }] : [] }); navigator.mediaSession.setActionHandler('play', () => getActivePlayer().play()); navigator.mediaSession.setActionHandler('pause', () => getActivePlayer().pause()); navigator.mediaSession.setActionHandler('previoustrack', playPrevious); navigator.mediaSession.setActionHandler('nexttrack', playNext); } } // ========== UI HELPERS ========== function showLoading(text) { loadingText.textContent = text || 'Loading...'; loadingOverlay.classList.remove('hidden'); errorMessage.classList.add('hidden'); } function hideLoading() { loadingOverlay.classList.add('hidden'); } function showError(message) { hideLoading(); errorText.textContent = message; errorMessage.classList.remove('hidden'); } errorRetry.addEventListener('click', () => { errorMessage.classList.add('hidden'); const query = searchInput.value.trim(); if (query) performSearch(query); }); function showEmptyState() { resultsContainer.innerHTML = `
🔍

Search for your favorite music

Or paste a Spotify link to an album or playlist

`; } function formatTime(seconds) { if (!seconds || isNaN(seconds)) return '0:00'; seconds = Math.floor(seconds); const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } // ========== KEYBOARD SHORTCUTS ========== document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; switch (e.code) { case 'Space': e.preventDefault(); togglePlay(); break; case 'ArrowRight': if (e.shiftKey) getActivePlayer().currentTime += 10; else playNext(); break; case 'ArrowLeft': if (e.shiftKey) getActivePlayer().currentTime -= 10; else playPrevious(); break; } }); // ========== SERVICE WORKER ========== if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(console.error); } // Initial state showEmptyState(); // ========== FULLSCREEN PLAYER & EXTRAS ========== window.removeFromQueue = function(index) { if (index === state.currentIndex) { // Removing currently playing state.queue.splice(index, 1); if (state.queue.length === 0) { getActivePlayer().pause(); state.isPlaying = false; updatePlayButton(); state.currentIndex = -1; // updatePlayerUI({ name: 'No track playing', artists: '-', album_art: '' }); // Reset UI playerTitle.textContent = 'No track playing'; playerArtist.textContent = '-'; playerArt.src = ''; // Reset FS fsTitle.textContent = 'No track playing'; fsArtist.textContent = 'Select music'; } else { // If we remove last item if (index >= state.queue.length) { state.currentIndex = 0; loadTrack(state.queue[0]); } else { // Index stays same (next track shifted into it) playTrack(state.queue[index]); } } } else { // Removing other track state.queue.splice(index, 1); if (index < state.currentIndex) { state.currentIndex--; } updateQueueUI(); } }; function toggleFullScreen() { fullscreenPlayer.classList.toggle('hidden'); if (!fullscreenPlayer.classList.contains('hidden')) { if (state.currentIndex >= 0) { updateFullscreenUI(state.queue[state.currentIndex]); } } } function updateFullscreenUI(track) { if (!track) return; fsTitle.textContent = track.name; const year = track.release_date ? track.release_date.slice(0, 4) : ''; fsArtist.textContent = year ? `${track.artists} â€ĸ ${year}` : track.artists; fsArt.src = track.album_art || '/static/icon.svg'; // Backdrop const backdrop = document.querySelector('.fs-backdrop'); if (backdrop) backdrop.style.backgroundImage = `url('${track.album_art || '/static/icon.svg'}')`; // DJ Mode Info for Fullscreen const fsDJInfo = $('#fs-dj-info'); if (state.djMode && fsDJInfo) { const isLocal = track.id?.startsWith('local_'); const feat = isLocal ? track.audio_features : state.audioFeaturesCache[track.id]; if (feat) { const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; fsDJInfo.innerHTML = `
${feat.bpm} BPM ${feat.camelot}
`; fsDJInfo.classList.remove('hidden'); } else { fsDJInfo.classList.add('hidden'); } } else if (fsDJInfo) { fsDJInfo.classList.add('hidden'); } updateFSPlayBtn(); } function updateFSPlayBtn() { if (!fsPlayBtn) return; fsPlayBtn.textContent = state.isPlaying ? '⏸' : 'â–ļ'; } // FS Controls if (fsToggleBtn) fsToggleBtn.addEventListener('click', toggleFullScreen); if (fsCloseBtn) fsCloseBtn.addEventListener('click', toggleFullScreen); if (fsPlayBtn) fsPlayBtn.addEventListener('click', () => playBtn.click()); // FS Prev/Next - seek Âą15s for podcasts, otherwise prev/next track const fsHeartBtn = $('#fs-heart-btn'); if (fsPrevBtn) { fsPrevBtn.addEventListener('click', () => { const currentTrack = state.queue[state.currentIndex]; const player = getActivePlayer(); if (currentTrack && currentTrack.source === 'podcast') { player.currentTime = Math.max(0, player.currentTime - 15); } else { prevBtn.click(); } }); } if (fsNextBtn) { fsNextBtn.addEventListener('click', () => { const currentTrack = state.queue[state.currentIndex]; const player = getActivePlayer(); if (currentTrack && currentTrack.source === 'podcast') { player.currentTime = Math.min(player.duration, player.currentTime + 15); } else { nextBtn.click(); } }); } // FS Heart button - add current track to playlist if (fsHeartBtn) { fsHeartBtn.addEventListener('click', () => { const currentTrack = state.queue[state.currentIndex]; if (currentTrack && window.openAddToPlaylistModal) { window.openAddToPlaylistModal(currentTrack); } else { showToast('No track playing'); } }); } // More Menu Controls const moreControlsBtn = $('#more-controls-btn'); const playerMoreMenu = $('#player-more-menu'); if (moreControlsBtn && playerMoreMenu) { moreControlsBtn.addEventListener('click', (e) => { e.stopPropagation(); playerMoreMenu.classList.toggle('hidden'); }); // Close menu when clicking outside document.addEventListener('click', (e) => { if (!playerMoreMenu.classList.contains('hidden') && !playerMoreMenu.contains(e.target) && e.target !== moreControlsBtn) { playerMoreMenu.classList.add('hidden'); } }); } if (fsNextBtn) fsNextBtn.addEventListener('click', () => nextBtn.click()); if (fsProgressBar) { fsProgressBar.addEventListener('input', (e) => { const player = getActivePlayer(); if (player.duration) { player.currentTime = (e.target.value / 100) * player.duration; } }); } // Navigation Links playerTitle.classList.add('clickable-link'); playerArtist.classList.add('clickable-link'); playerTitle.addEventListener('click', () => { if (state.currentIndex >= 0 && !fullscreenPlayer.classList.contains('hidden')) toggleFullScreen(); // Close FS if open? Or works anyway. if (state.currentIndex >= 0) { const track = state.queue[state.currentIndex]; performSearch(track.name + " " + track.artists); } }); playerArtist.addEventListener('click', () => { if (state.currentIndex >= 0) { performSearch(state.queue[state.currentIndex].artists); } }); // ========== TOAST NOTIFICATIONS ========== function showToast(message) { const toast = document.createElement('div'); toast.className = 'toast'; toast.textContent = message; toastContainer.appendChild(toast); // Remove after animation setTimeout(() => toast.remove(), 3000); } // ========== VOLUME CONTROL ========== // Shared volume update function function updateVolume(vol) { if (vol < 0) vol = 0; if (vol > 1) vol = 1; state.volume = vol; audioPlayer.volume = vol; audioPlayer2.volume = vol; // Apply to both players state.muted = vol === 0; // Persist volume to localStorage localStorage.setItem('freedify_volume', vol.toString()); // Update main slider UI if needed const sliderVal = Math.round(vol * 100); if (volumeSlider.value != sliderVal) volumeSlider.value = sliderVal; // Update PiP slider if exists if (pipWindow) { const waVol = pipWindow.document.getElementById('wa-vol'); if (waVol && waVol.value != sliderVal) waVol.value = sliderVal; } updateMuteIcon(); } volumeSlider.addEventListener('input', (e) => { updateVolume(e.target.value / 100); }); muteBtn.addEventListener('click', () => { state.muted = !state.muted; if (state.muted) { audioPlayer.volume = 0; volumeSlider.value = 0; } else { audioPlayer.volume = state.volume || 1; volumeSlider.value = (state.volume || 1) * 100; } updateMuteIcon(); }); function updateMuteIcon() { if (state.muted || audioPlayer.volume === 0) { muteBtn.textContent = '🔇'; } else if (audioPlayer.volume < 0.5) { muteBtn.textContent = '🔉'; } else { muteBtn.textContent = '🔊'; } } // ========== REPEAT MODE ========== repeatBtn.addEventListener('click', () => { // Cycle: none -> all -> one -> none if (state.repeatMode === 'none') { state.repeatMode = 'all'; repeatBtn.classList.add('repeat-active'); repeatBtn.title = 'Repeat: All'; showToast('Repeat: All'); } else if (state.repeatMode === 'all') { state.repeatMode = 'one'; repeatBtn.classList.add('repeat-one'); repeatBtn.title = 'Repeat: One'; showToast('Repeat: One'); } else { state.repeatMode = 'none'; repeatBtn.classList.remove('repeat-active', 'repeat-one'); repeatBtn.title = 'Repeat: Off'; showToast('Repeat: Off'); } }); // Override playNext for repeat handling const originalPlayNext = playNext; window.playNextWithRepeat = function() { // Skip if gapless already handled this transition if (crossfadeTimeout) return; const player = getActivePlayer(); if (state.repeatMode === 'one') { player.currentTime = 0; player.play(); return; } if (state.currentIndex < state.queue.length - 1) { state.currentIndex++; // Try to use preloaded player (no loading screen) - ONLY if ready if (preloadedReady && preloadedPlayer && preloadedTrackId === state.queue[state.currentIndex]?.id) { console.log('playNextWithRepeat: Using preloaded player for:', state.queue[state.currentIndex].name); preloadedTrackId = null; preloadedReady = false; updatePlayerUI(); updateQueueUI(); updateFullscreenUI(state.queue[state.currentIndex]); performGaplessSwitch(); updateFormatBadge(getActivePlayer().src); updateMediaSession(state.queue[state.currentIndex]); setTimeout(preloadNextTrack, 500); } else { loadTrack(state.queue[state.currentIndex]); } } else if (state.repeatMode === 'all' && state.queue.length > 0) { state.currentIndex = 0; loadTrack(state.queue[0]); } }; // Replace ended handler with repeat-aware version audioPlayer.removeEventListener('ended', playNext); audioPlayer.addEventListener('ended', window.playNextWithRepeat); // ========== KEYBOARD SHORTCUTS ========== shortcutsClose.addEventListener('click', () => { shortcutsHelp.classList.add('hidden'); }); document.addEventListener('keydown', (e) => { // Skip if typing in input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; switch (e.key) { case ' ': e.preventDefault(); togglePlay(); break; case 'ArrowRight': if (e.shiftKey) { const player = getActivePlayer(); player.currentTime = Math.min(player.duration, player.currentTime + 10); } else { playNext(); } break; case 'ArrowLeft': if (e.shiftKey) { const player = getActivePlayer(); player.currentTime = Math.max(0, player.currentTime - 10); } else { playPrevious(); } break; case 'ArrowUp': e.preventDefault(); updateVolume(Math.min(1, state.volume + 0.1)); showToast(`Volume: ${Math.round(state.volume * 100)}%`); break; case 'ArrowDown': e.preventDefault(); updateVolume(Math.max(0, state.volume - 0.1)); showToast(`Volume: ${Math.round(state.volume * 100)}%`); break; case 'm': case 'M': muteBtn.click(); break; case 's': case 'S': shuffleQueueBtn.click(); showToast('Queue Shuffled'); break; case 'r': case 'R': repeatBtn.click(); break; case 'f': case 'F': toggleFullScreen(); break; case 'q': case 'Q': queueSection.classList.toggle('hidden'); break; case '?': shortcutsHelp.classList.toggle('hidden'); break; } }); // ========== QUEUE DRAG & DROP ========== let draggedItem = null; let draggedIndex = -1; function initQueueDragDrop() { const items = queueContainer.querySelectorAll('.queue-item'); items.forEach((item, index) => { item.setAttribute('draggable', 'true'); item.addEventListener('dragstart', (e) => { draggedItem = item; draggedIndex = index; item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); item.addEventListener('dragend', () => { item.classList.remove('dragging'); draggedItem = null; draggedIndex = -1; items.forEach(i => i.classList.remove('drag-over')); }); item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (item !== draggedItem) { item.classList.add('drag-over'); } }); item.addEventListener('dragleave', () => { item.classList.remove('drag-over'); }); item.addEventListener('drop', (e) => { e.preventDefault(); if (item === draggedItem) return; const targetIndex = index; // Reorder queue const [movedTrack] = state.queue.splice(draggedIndex, 1); state.queue.splice(targetIndex, 0, movedTrack); // Update current index if needed if (state.currentIndex === draggedIndex) { state.currentIndex = targetIndex; } else if (draggedIndex < state.currentIndex && targetIndex >= state.currentIndex) { state.currentIndex--; } else if (draggedIndex > state.currentIndex && targetIndex <= state.currentIndex) { state.currentIndex++; } updateQueueUI(); showToast('Queue reordered'); }); // Add track to queue function addToQueue(track) { if (!track) return; state.queue.push(track); updateQueueUI(); showToast(`Added "${track.name}" to queue`); } }); } // Patch updateQueueUI to init drag-drop const originalUpdateQueueUI = updateQueueUI; window.updateQueueUIPatched = function() { originalUpdateQueueUI(); initQueueDragDrop(); }; // Override the function (need to call it after original) const _originalUpdateQueueUI = updateQueueUI; updateQueueUI = function() { _originalUpdateQueueUI.apply(this, arguments); setTimeout(initQueueDragDrop, 0); }; // ========== EQUALIZER (Web Audio API) ========== const eqPanel = $('#eq-panel'); const eqToggleBtn = $('#eq-toggle-btn'); const eqCloseBtn = $('#eq-close-btn'); const eqPresets = $$('.eq-preset'); const bassBoostSlider = $('#bass-boost'); const bassBoostVal = $('#bass-boost-val'); const volumeBoostSlider = $('#volume-boost'); const volumeBoostVal = $('#volume-boost-val'); // Audio context and nodes (created lazily) let audioContext = null; let sourceNode = null; let sourceNode2 = null; let gainNode1 = null; // Gain for player 1 (for crossfade) let gainNode2 = null; // Gain for player 2 (for crossfade) let eqFilters = []; let bassBoostFilter = null; let volumeBoostGain = null; let eqConnected = false; // EQ frequency bands const EQ_BANDS = [ { id: 'eq-60', freq: 60, type: 'lowshelf' }, { id: 'eq-230', freq: 230, type: 'peaking' }, { id: 'eq-910', freq: 910, type: 'peaking' }, { id: 'eq-3600', freq: 3600, type: 'peaking' }, { id: 'eq-7500', freq: 7500, type: 'highshelf' } ]; // Presets (dB values for each band) const EQ_PRESETS = { flat: [0, 0, 0, 0, 0], bass: [6, 4, 0, 0, 0], treble: [0, 0, 0, 3, 6], vocal: [-2, 0, 4, 2, -1] }; function initEqualizer() { if (audioContext) return; // Already initialized try { audioContext = new (window.AudioContext || window.webkitAudioContext)(); // Create source nodes for both audio players sourceNode = audioContext.createMediaElementSource(audioPlayer); sourceNode2 = audioContext.createMediaElementSource(audioPlayer2); // Create gain nodes for crossfade control gainNode1 = audioContext.createGain(); gainNode2 = audioContext.createGain(); gainNode1.gain.value = 1; // Player 1 starts active gainNode2.gain.value = 0; // Player 2 starts silent // Create EQ filter nodes eqFilters = EQ_BANDS.map(band => { const filter = audioContext.createBiquadFilter(); filter.type = band.type; filter.frequency.value = band.freq; filter.gain.value = 0; if (band.type === 'peaking') filter.Q.value = 1; return filter; }); // Create bass boost filter (low shelf) bassBoostFilter = audioContext.createBiquadFilter(); bassBoostFilter.type = 'lowshelf'; bassBoostFilter.frequency.value = 100; bassBoostFilter.gain.value = 0; // Create volume boost gain node volumeBoostGain = audioContext.createGain(); volumeBoostGain.gain.value = 1; // Connect chains: // Player 1: source -> gain1 -> first EQ filter // Player 2: source2 -> gain2 -> first EQ filter // Then: EQ chain -> bass boost -> volume boost -> destination sourceNode.connect(gainNode1); sourceNode2.connect(gainNode2); // Both gains merge into first EQ filter const firstFilter = eqFilters[0]; gainNode1.connect(firstFilter); gainNode2.connect(firstFilter); // Connect EQ filter chain let lastNode = firstFilter; for (let i = 1; i < eqFilters.length; i++) { lastNode.connect(eqFilters[i]); lastNode = eqFilters[i]; } lastNode.connect(bassBoostFilter); bassBoostFilter.connect(volumeBoostGain); volumeBoostGain.connect(audioContext.destination); eqConnected = true; // Load saved settings loadEqSettings(); console.log('Equalizer initialized with crossfade support'); } catch (e) { console.error('Failed to initialize equalizer:', e); } } function loadEqSettings() { const saved = localStorage.getItem('freedify_eq'); if (saved) { try { const settings = JSON.parse(saved); EQ_BANDS.forEach((band, i) => { const slider = $(`#${band.id}`); if (slider && settings.bands[i] !== undefined) { slider.value = settings.bands[i]; if (eqFilters[i]) eqFilters[i].gain.value = settings.bands[i]; } }); if (settings.bass !== undefined) { bassBoostSlider.value = settings.bass; if (bassBoostFilter) bassBoostFilter.gain.value = settings.bass; bassBoostVal.textContent = `${settings.bass}dB`; } if (settings.volume !== undefined) { volumeBoostSlider.value = settings.volume; if (volumeBoostGain) volumeBoostGain.gain.value = Math.pow(10, settings.volume / 20); volumeBoostVal.textContent = `${settings.volume}dB`; } } catch (e) { console.error('Error loading EQ settings:', e); } } } function saveEqSettings() { const settings = { bands: EQ_BANDS.map(band => parseFloat($(`#${band.id}`).value)), bass: parseFloat(bassBoostSlider.value), volume: parseFloat(volumeBoostSlider.value) }; localStorage.setItem('freedify_eq', JSON.stringify(settings)); } function applyPreset(preset) { const values = EQ_PRESETS[preset]; if (!values) return; EQ_BANDS.forEach((band, i) => { const slider = $(`#${band.id}`); slider.value = values[i]; if (eqFilters[i]) eqFilters[i].gain.value = values[i]; }); eqPresets.forEach(btn => btn.classList.remove('active')); document.querySelector(`[data-preset="${preset}"]`)?.classList.add('active'); saveEqSettings(); } // Toggle EQ panel eqToggleBtn?.addEventListener('click', () => { if (!audioContext) initEqualizer(); eqPanel.classList.toggle('hidden'); eqToggleBtn.classList.toggle('active'); }); eqCloseBtn?.addEventListener('click', () => { eqPanel.classList.add('hidden'); eqToggleBtn.classList.remove('active'); }); // Preset buttons eqPresets.forEach(btn => { btn.addEventListener('click', () => applyPreset(btn.dataset.preset)); }); // EQ band sliders EQ_BANDS.forEach((band, i) => { const slider = $(`#${band.id}`); slider?.addEventListener('input', () => { if (eqFilters[i]) eqFilters[i].gain.value = parseFloat(slider.value); saveEqSettings(); // Clear preset selection when manually adjusting eqPresets.forEach(btn => btn.classList.remove('active')); }); }); // Bass boost slider bassBoostSlider?.addEventListener('input', () => { const val = parseFloat(bassBoostSlider.value); if (bassBoostFilter) bassBoostFilter.gain.value = val; bassBoostVal.textContent = `${val}dB`; saveEqSettings(); }); // Volume boost slider volumeBoostSlider?.addEventListener('input', () => { const val = parseFloat(volumeBoostSlider.value); // Convert dB to gain multiplier: gain = 10^(dB/20) if (volumeBoostGain) volumeBoostGain.gain.value = Math.pow(10, val / 20); volumeBoostVal.textContent = `${val}dB`; saveEqSettings(); }); // Initialize EQ when audio starts playing (to resume AudioContext) audioPlayer.addEventListener('play', () => { if (!audioContext) { initEqualizer(); } else if (audioContext.state === 'suspended') { audioContext.resume(); } }); // ========== THEME PICKER ========== const themeBtn = $('#theme-btn'); const themePicker = $('#theme-picker'); const themeOptions = $$('.theme-option'); // Load saved theme on startup (function loadSavedTheme() { const savedTheme = localStorage.getItem('freedify_theme') || ''; if (savedTheme) { document.body.classList.add(savedTheme); } // Mark active option themeOptions.forEach(opt => { if (opt.dataset.theme === savedTheme) { opt.classList.add('active'); } }); // Sync meta theme-color on load const metaThemeColor = document.querySelector('meta[name="theme-color"]'); if (metaThemeColor && savedTheme) { setTimeout(() => { const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); if (accentColor) metaThemeColor.content = accentColor; }, 50); } })(); // Toggle theme picker themeBtn?.addEventListener('click', (e) => { e.stopPropagation(); themePicker.classList.toggle('hidden'); }); // Theme selection themeOptions.forEach(opt => { opt.addEventListener('click', () => { const newTheme = opt.dataset.theme; // Remove all theme classes document.body.classList.remove('theme-purple', 'theme-blue', 'theme-green', 'theme-pink', 'theme-orange'); // Add new theme if (newTheme) { document.body.classList.add(newTheme); } // Save to localStorage localStorage.setItem('freedify_theme', newTheme); // Update active state themeOptions.forEach(o => o.classList.remove('active')); opt.classList.add('active'); // Close picker themePicker.classList.add('hidden'); showToast(`Theme changed to ${opt.textContent}`); // Update meta theme-color for mobile browser UI const metaThemeColor = document.querySelector('meta[name="theme-color"]'); if (metaThemeColor) { // Get computed color for current theme // Wait a tick for class change to apply setTimeout(() => { const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); if (accentColor) metaThemeColor.content = accentColor; }, 50); } }); }); // Close theme picker when clicking outside document.addEventListener('click', (e) => { if (!themePicker.contains(e.target) && e.target !== themeBtn) { themePicker.classList.add('hidden'); } }); // ========== MEDIA SESSION API (Lock Screen Controls) ========== function updateMediaSession(track) { if (!('mediaSession' in navigator)) return; navigator.mediaSession.metadata = new MediaMetadata({ title: track.name || 'Unknown Track', artist: track.artists || 'Unknown Artist', album: track.album || '', artwork: [ { src: track.album_art || '/static/icon.svg', sizes: '512x512', type: 'image/png' } ] }); } // Set up Media Session action handlers if ('mediaSession' in navigator) { navigator.mediaSession.setActionHandler('play', () => { getActivePlayer().play(); }); navigator.mediaSession.setActionHandler('pause', () => { getActivePlayer().pause(); }); navigator.mediaSession.setActionHandler('previoustrack', () => { playPrevious(); }); navigator.mediaSession.setActionHandler('nexttrack', () => { playNext(); }); navigator.mediaSession.setActionHandler('seekbackward', (details) => { const player = getActivePlayer(); player.currentTime = Math.max(player.currentTime - (details.seekOffset || 10), 0); }); navigator.mediaSession.setActionHandler('seekforward', (details) => { const player = getActivePlayer(); player.currentTime = Math.min(player.currentTime + (details.seekOffset || 10), player.duration); }); navigator.mediaSession.setActionHandler('seekto', (details) => { const player = getActivePlayer(); if (details.fastSeek && 'fastSeek' in player) { player.fastSeek(details.seekTime); } else { player.currentTime = details.seekTime; } }); } // Update position state periodically audioPlayer.addEventListener('timeupdate', () => { if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) { try { if (audioPlayer.duration && !isNaN(audioPlayer.duration)) { navigator.mediaSession.setPositionState({ duration: audioPlayer.duration, playbackRate: audioPlayer.playbackRate, position: audioPlayer.currentTime }); } } catch (e) { /* Ignore errors */ } } }); // ========== GOOGLE DRIVE SYNC ========== // Client ID: fetched from server env var, or from localStorage, or prompted let GOOGLE_CLIENT_ID = localStorage.getItem('freedify_google_client_id') || ''; // Expanded scope: appdata for favorites sync + drive.file for saving audio files const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/drive.file'; const SYNC_FILENAME = 'freedify_playlists.json'; const FREEDIFY_FOLDER_NAME = 'Freedify'; let googleAccessToken = null; // Fetch server-side config (Google Client ID from env vars) (async function loadServerConfig() { try { const res = await fetch('/api/config'); if (res.ok) { const config = await res.json(); if (config.google_client_id) { GOOGLE_CLIENT_ID = config.google_client_id; console.log('Google Client ID loaded from server config'); } } } catch (e) { console.log('Could not load server config:', e.message); } })(); const syncBtn = $('#sync-btn'); // Initialize Google API // Initialize Google API window.initGoogleApi = function() { return new Promise((resolve) => { if (typeof gapi === 'undefined') { console.log('Google API not loaded yet'); resolve(false); return; } gapi.load('client', async () => { try { await gapi.client.init({ discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'] }); console.log("Google Drive API initialized"); resolve(true); } catch (e) { console.error('Failed to init Google API:', e); resolve(false); } }); }); }; // If gapi is already loaded (race condition), init immediately if (typeof gapi !== 'undefined') { window.initGoogleApi(); } // Google Sign-In async function signInWithGoogle() { if (!GOOGLE_CLIENT_ID) { const clientId = prompt( 'Enter your Google OAuth Client ID:\n\n' + 'To get one:\n' + '1. Go to console.cloud.google.com\n' + '2. Create a project\n' + '3. Enable Drive API\n' + '4. Create OAuth credentials (Web application)\n' + '5. Add your domain to authorized origins' ); if (clientId) { localStorage.setItem('freedify_google_client_id', clientId); location.reload(); } return null; } return new Promise((resolve) => { const client = google.accounts.oauth2.initTokenClient({ client_id: GOOGLE_CLIENT_ID, scope: GOOGLE_SCOPES, callback: (response) => { if (response.access_token) { googleAccessToken = response.access_token; gapi.client.setToken({ access_token: googleAccessToken }); syncBtn.classList.add('synced'); showToast('Signed in to Google Drive'); resolve(response.access_token); } else { resolve(null); } }, error_callback: (error) => { console.error('Google sign-in error:', error); showToast('Sign-in failed'); resolve(null); } }); client.requestAccessToken(); }); } // Find sync file in Drive async function findSyncFile() { try { const response = await gapi.client.drive.files.list({ spaces: 'appDataFolder', q: `name='${SYNC_FILENAME}'`, fields: 'files(id, name, modifiedTime)' }); return response.result.files?.[0] || null; } catch (e) { console.error('Error finding sync file:', e); return null; } } // Find or create "Freedify" folder in Drive root async function findOrCreateFreedifyFolder() { try { // Search for existing folder const response = await gapi.client.drive.files.list({ q: `name='${FREEDIFY_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`, fields: 'files(id, name)' }); if (response.result.files && response.result.files.length > 0) { return response.result.files[0].id; } // Create folder if not found const createResponse = await gapi.client.drive.files.create({ resource: { name: FREEDIFY_FOLDER_NAME, mimeType: 'application/vnd.google-apps.folder' }, fields: 'id' }); return createResponse.result.id; } catch (e) { console.error('Error finding/creating folder:', e); return null; } } // Upload to Drive with Granular Support // syncType: 'all', 'playlists', 'queue' async function uploadToDrive(syncType = 'all') { if (!googleAccessToken) { await signInWithGoogle(); if (!googleAccessToken) return; // User cancelled auth } const loadingText = syncType === 'all' ? 'Syncing all to Drive...' : syncType === 'playlists' ? 'Syncing playlists...' : 'Syncing queue...'; showLoading(loadingText); try { // 1. Fetch EXISTING file first to preserve data we aren't updating const existingFile = await findSyncFile(); let currentRemoteData = {}; if (existingFile) { try { const response = await fetch( `https://www.googleapis.com/drive/v3/files/${existingFile.id}?alt=media`, { headers: { 'Authorization': `Bearer ${googleAccessToken}` } } ); if (response.ok) { const json = await response.json(); // Handle legacy array format if (Array.isArray(json)) { currentRemoteData = { playlists: json, queue: [] }; } else { currentRemoteData = json; } } } catch (err) { console.warn('Failed to read existing sync data, starting fresh', err); } } // 2. Prepare NEW data by merging state into remote data const syncData = { playlists: currentRemoteData.playlists || [], queue: currentRemoteData.queue || [], currentIndex: currentRemoteData.currentIndex || 0, volume: currentRemoteData.volume || 1, syncedAt: new Date().toISOString() }; if (syncType === 'all' || syncType === 'playlists') { syncData.playlists = state.playlists; } if (syncType === 'all' || syncType === 'queue') { syncData.queue = state.queue; syncData.currentIndex = state.currentIndex; syncData.volume = state.volume; } // 3. Upload const fileContent = JSON.stringify(syncData, null, 2); const metadata = { name: SYNC_FILENAME, mimeType: 'application/json' }; if (!existingFile) { metadata.parents = ['appDataFolder']; } const form = new FormData(); form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); form.append('file', new Blob([fileContent], { type: 'application/json' })); const url = existingFile ? `https://www.googleapis.com/upload/drive/v3/files/${existingFile.id}?uploadType=multipart` : 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; const response = await fetch(url, { method: existingFile ? 'PATCH' : 'POST', headers: { 'Authorization': `Bearer ${googleAccessToken}` }, body: form }); if (response.ok) { hideLoading(); // Close modal if open $('#drive-sync-modal').classList.add('hidden'); let msg = 'Sync Complete!'; if (syncType === 'playlists') msg = `Synced ${state.playlists.length} playlists to Drive`; if (syncType === 'queue') msg = `Synced queue (${state.queue.length} tracks)`; if (syncType === 'all') msg = `Synced Match & Queue to Drive`; showToast(msg); localStorage.setItem('freedify_last_sync', new Date().toISOString()); } else { throw new Error('Upload failed'); } } catch (e) { console.error('Upload error:', e); hideLoading(); showError('Failed to sync to Google Drive'); } } // Download from Drive with Granular Support async function downloadFromDrive(syncType = 'all') { if (!googleAccessToken) { await signInWithGoogle(); if (!googleAccessToken) return; } showLoading('Loading from Google Drive...'); try { const file = await findSyncFile(); if (!file) { hideLoading(); showToast('No saved data found in Drive'); return; } const response = await fetch( `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`, { headers: { 'Authorization': `Bearer ${googleAccessToken}` } } ); if (response.ok) { const syncData = await response.json(); // Normalize data (handle legacy) const remotePlaylists = Array.isArray(syncData) ? syncData : (syncData.playlists || []); const remoteQueue = Array.isArray(syncData) ? [] : (syncData.queue || []); const remoteIndex = Array.isArray(syncData) ? 0 : (syncData.currentIndex || 0); let restoredCount = 0; // Apply updates if (syncType === 'all' || syncType === 'playlists') { state.playlists = remotePlaylists; savePlaylists(); restoredCount = remotePlaylists.length; // If favorites view is active, refresh it if (state.searchType === 'favorites') renderPlaylistsView(); } if (syncType === 'all' || syncType === 'queue') { if (remoteQueue.length > 0) { state.queue = remoteQueue; state.currentIndex = remoteIndex; // Use remote volume only if 'all' to avoid startling volume jumps on just queue sync? // Let's stick to syncing volume on queue sync. if (syncData.volume) { state.volume = syncData.volume; if (audioPlayer) audioPlayer.volume = state.volume; if (audioPlayer2) audioPlayer2.volume = state.volume; if (volumeSlider) volumeSlider.value = Math.round(state.volume * 100); } updateQueueUI(); updatePlayerUI(); } } hideLoading(); // Close modal $('#drive-sync-modal').classList.add('hidden'); if (syncType === 'playlists') showToast(`Loaded ${restoredCount} playlists`); else if (syncType === 'queue') showToast(`Loaded queue (${remoteQueue.length} tracks)`); else showToast(`Loaded Library & Session`); } else { throw new Error('Download failed'); } } catch (e) { console.error('Download error:', e); hideLoading(); showError('Failed to load from Google Drive'); } } // --- Drive Sync Modal UI & Events --- function updateDriveModalUI() { const authSection = $('#drive-auth-section'); const optionsSection = $('#drive-options-section'); const userEmailSpan = $('#drive-user-email'); // Check if we have a valid access token (GIS flow stores it in googleAccessToken) if (googleAccessToken) { authSection.classList.add('hidden'); optionsSection.classList.remove('hidden'); if (userEmailSpan) userEmailSpan.textContent = 'Connected to Google Drive'; } else { authSection.classList.remove('hidden'); optionsSection.classList.add('hidden'); } } async function showDriveModal() { const modal = $('#drive-sync-modal'); modal.classList.remove('hidden'); // Ensure API is ready if (typeof gapi !== 'undefined' && (!gapi.auth2 || !gapi.client.drive)) { $('#drive-loading').classList.remove('hidden'); await initGoogleApi(); $('#drive-loading').classList.add('hidden'); } updateDriveModalUI(); } // Open Modal syncBtn?.addEventListener('click', () => { showDriveModal(); }); // Close Modal $('#drive-modal-close')?.addEventListener('click', () => $('#drive-sync-modal').classList.add('hidden')); $('#drive-modal-close-top')?.addEventListener('click', () => $('#drive-sync-modal').classList.add('hidden')); // Auth Buttons $('#drive-signin-btn')?.addEventListener('click', async () => { await signInWithGoogle(); updateDriveModalUI(); }); $('#drive-signout-btn')?.addEventListener('click', () => { googleAccessToken = null; gapi.client.setToken(null); syncBtn?.classList.remove('synced'); updateDriveModalUI(); showToast('Signed out from Google Drive'); }); // Granular Action Bindings $('#drive-up-all')?.addEventListener('click', () => uploadToDrive('all')); $('#drive-up-playlists')?.addEventListener('click', () => uploadToDrive('playlists')); $('#drive-up-queue')?.addEventListener('click', () => uploadToDrive('queue')); $('#drive-down-all')?.addEventListener('click', () => downloadFromDrive('all')); $('#drive-down-playlists')?.addEventListener('click', () => downloadFromDrive('playlists')); $('#drive-down-queue')?.addEventListener('click', () => downloadFromDrive('queue')); // ========== PODCAST EPISODE DETAILS MODAL ========== const podcastModal = $('#podcast-modal'); const podcastModalClose = $('#podcast-modal-close'); const podcastModalArt = $('#podcast-modal-art'); const podcastModalTitle = $('#podcast-modal-title'); const podcastModalDate = $('#podcast-modal-date'); const podcastModalDuration = $('#podcast-modal-duration'); const podcastModalDescription = $('#podcast-modal-description'); const podcastModalPlay = $('#podcast-modal-play'); let currentPodcastEpisode = null; function showPodcastModal(track) { if (!track || track.source !== 'podcast') return; currentPodcastEpisode = track; podcastModalArt.src = track.album_art || '/static/icon.svg'; podcastModalTitle.textContent = track.name; podcastModalDate.textContent = track.datePublished || ''; podcastModalDuration.textContent = `Duration: ${track.duration}`; // Strip HTML tags from description and decode entities const tempDiv = document.createElement('div'); tempDiv.innerHTML = track.description || 'No description available.'; podcastModalDescription.textContent = tempDiv.textContent || tempDiv.innerText; podcastModal.classList.remove('hidden'); } window.showPodcastModal = showPodcastModal; function hidePodcastModal() { podcastModal.classList.add('hidden'); currentPodcastEpisode = null; } // Close modal podcastModalClose?.addEventListener('click', hidePodcastModal); // Close on backdrop click podcastModal?.addEventListener('click', (e) => { if (e.target === podcastModal) hidePodcastModal(); }); // Play button podcastModalPlay?.addEventListener('click', () => { if (currentPodcastEpisode) { playTrack(currentPodcastEpisode); hidePodcastModal(); } }); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !podcastModal.classList.contains('hidden')) { hidePodcastModal(); } }); // ========== HiFi MODE ========== const hifiBtn = $('#hifi-btn'); // Initialize HiFi button state - reflects actual playing quality, not just preference // Initialize HiFi button state - reflects user preference and source limits function updateHifiButtonUI() { if (hifiBtn) { const currentTrack = state.queue[state.currentIndex]; const source = currentTrack?.source || ''; // Determine constraint based on source const isLossySource = source === 'ytmusic' || source === 'youtube' || source === 'podcast' || source === 'import'; if (isLossySource) { // Force Lossy display if source is known bad hifiBtn.classList.remove('hi-res'); hifiBtn.classList.add('active', 'lossy'); hifiBtn.title = "Playing: Compressed Audio (MP3/AAC)"; hifiBtn.textContent = "MP3"; } else { // For all other sources (HiFi, Hi-Res, Unknown), reflect the MODE setting hifiBtn.classList.add('active'); hifiBtn.classList.remove('lossy'); // Toggle Hi-Res vs HiFi based on state // If state.hiResMode is true -> Add class 'hi-res' -> CSS makes it Cyan/Pulse // If state.hiResMode is false -> Remove class 'hi-res' -> CSS makes it Green hifiBtn.classList.toggle('hi-res', state.hiResMode); hifiBtn.title = state.hiResMode ? "Hi-Res Mode ON (24-bit)" : "HiFi Mode ON (16-bit)"; hifiBtn.textContent = state.hiResMode ? "Hi-Res" : "HiFi"; } } } // Toggle HiFi mode if (hifiBtn) { hifiBtn.addEventListener('click', () => { state.hiResMode = !state.hiResMode; localStorage.setItem('freedify_hires', state.hiResMode); updateHifiButtonUI(); // Show toast notification showToast(state.hiResMode ? '💎 Hi-Res Mode ON - 24-bit Audio' : 'đŸŽĩ HiFi Mode ON - 16-bit Audio', 3000); }); // Initialize UI on load updateHifiButtonUI(); } // ========== DJ MODE ========== const djModeBtn = $('#dj-mode-btn'); const djSetlistModal = $('#dj-setlist-modal'); const djModalClose = $('#dj-modal-close'); const djStyleSelect = $('#dj-style-select'); const djSetlistLoading = $('#dj-setlist-loading'); const djSetlistResults = $('#dj-setlist-results'); const djOrderedTracks = $('#dj-ordered-tracks'); const djGenerateBtn = $('#dj-generate-btn'); const djApplyBtn = $('#dj-apply-btn'); // Musical Key to Camelot Wheel conversion function musicalKeyToCamelot(key) { if (!key) return null; // Normalize key: uppercase, handle sharps/flats const normalized = key.trim() .replace(/major/i, '') .replace(/minor/i, 'm') .replace(/♯/g, '#') .replace(/♭/g, 'b') .trim(); // Mapping of musical keys to Camelot notation // Minor keys (A column) const minorKeys = { 'Abm': '1A', 'G#m': '1A', 'Ebm': '2A', 'D#m': '2A', 'Bbm': '3A', 'A#m': '3A', 'Fm': '4A', 'Cm': '5A', 'Gm': '6A', 'Dm': '7A', 'Am': '8A', 'Em': '9A', 'Bm': '10A', 'F#m': '11A', 'Gbm': '11A', 'Dbm': '12A', 'C#m': '12A' }; // Major keys (B column) const majorKeys = { 'B': '1B', 'Gb': '2B', 'F#': '2B', 'Db': '3B', 'C#': '3B', 'Ab': '4B', 'G#': '4B', 'Eb': '5B', 'D#': '5B', 'Bb': '6B', 'A#': '6B', 'F': '7B', 'C': '8B', 'G': '9B', 'D': '10B', 'A': '11B', 'E': '12B' }; // Check minor first, then major if (minorKeys[normalized]) return minorKeys[normalized]; if (majorKeys[normalized]) return majorKeys[normalized]; // Try case-insensitive match for (const [k, v] of Object.entries(minorKeys)) { if (k.toLowerCase() === normalized.toLowerCase()) return v; } for (const [k, v] of Object.entries(majorKeys)) { if (k.toLowerCase() === normalized.toLowerCase()) return v; } // If already in Camelot format, return as-is if (/^[1-9][0-2]?[AB]$/i.test(normalized)) { return normalized.toUpperCase(); } return key; // Return original if no match } // DJ Mode state state.djMode = localStorage.getItem('freedify_dj_mode') === 'true'; state.audioFeaturesCache = {}; // Cache audio features by track ID state.lastSetlistResult = null; // Initialize DJ mode on load if (state.djMode) { document.body.classList.add('dj-mode-active'); } // Toggle DJ mode djModeBtn?.addEventListener('click', () => { state.djMode = !state.djMode; localStorage.setItem('freedify_dj_mode', state.djMode); document.body.classList.toggle('dj-mode-active', state.djMode); if (state.djMode) { showToast('🎧 DJ Mode activated'); // Fetch audio features for current queue if (state.queue.length > 0) { fetchAudioFeaturesForQueue(); } } else { showToast('DJ Mode deactivated'); } }); // Helper to render DJ Badge function renderDJBadgeForTrack(track) { if (!state.djMode) return ''; // For local tracks, use embedded audio_features directly (trust Serato) const isLocal = track.id?.startsWith('local_'); const feat = isLocal ? track.audio_features : state.audioFeaturesCache[track.id]; if (!feat) return '
'; const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; return `
${feat.bpm} BPM ${feat.camelot}
`; } // Generic fetch features for any list of tracks async function fetchAudioFeaturesForTracks(tracks) { if (!state.djMode || !tracks || tracks.length === 0) return; // Filter out already cached AND local files (trust local metadata) const tracksToFetch = tracks .filter(t => t.id && !t.id.startsWith('LINK:') && !t.id.startsWith('pod_') && !t.id.startsWith('local_')) .filter(t => !state.audioFeaturesCache[t.id]) .map(t => ({ id: t.id, isrc: t.isrc || null, name: t.name || null, artists: t.artists || null })); // De-duplicate by ID const uniqueTracks = []; const seenIds = new Set(); tracksToFetch.forEach(t => { if (!seenIds.has(t.id)) { seenIds.add(t.id); uniqueTracks.push(t); } }); if (uniqueTracks.length === 0) return; try { const response = await fetch('/api/audio-features/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: uniqueTracks }) }); if (response.ok) { const data = await response.json(); data.features.forEach((feat, i) => { if (feat) { state.audioFeaturesCache[uniqueTracks[i].id] = feat; } }); // Trigger UI updates updateDJBadgesInUI(); updatePlayerUI(); } } catch (err) { console.warn('Failed to fetch audio features:', err); } } // Update all badges in DOM function updateDJBadgesInUI() { // Update placeholders $$('.dj-badge-placeholder').forEach(el => { const id = el.dataset.id; const feat = state.audioFeaturesCache[id]; if (feat) { const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; el.outerHTML = `
${feat.bpm} BPM ${feat.camelot}
`; } }); // Update Player if (state.currentIndex >= 0 && state.queue[state.currentIndex]) { updatePlayerUI(); } } // Fetch audio features for tracks in queue async function fetchAudioFeaturesForQueue() { await fetchAudioFeaturesForTracks(state.queue); addDJBadgesToQueue(); } // Open DJ setlist modal function openDJSetlistModal() { if (state.queue.length < 3) { showToast('Add at least 3 tracks to queue for setlist generation'); return; } djSetlistModal?.classList.remove('hidden'); djSetlistLoading?.classList.add('hidden'); djSetlistResults?.classList.add('hidden'); djApplyBtn?.classList.add('hidden'); state.lastSetlistResult = null; } function closeDJSetlistModal() { djSetlistModal?.classList.add('hidden'); } djModalClose?.addEventListener('click', closeDJSetlistModal); djSetlistModal?.addEventListener('click', (e) => { if (e.target === djSetlistModal) closeDJSetlistModal(); }); // Generate setlist djGenerateBtn?.addEventListener('click', async () => { // Ensure we have audio features await fetchAudioFeaturesForQueue(); // Build tracks data - use embedded audio_features for local, cache for others const tracksData = state.queue.map(t => { const isLocal = t.id.startsWith('local_'); const feat = isLocal ? t.audio_features : state.audioFeaturesCache[t.id]; return { id: t.id, name: t.name, artists: t.artists, bpm: feat?.bpm || 0, camelot: feat?.camelot || '?', energy: feat?.energy || 0.5 }; }); djSetlistLoading?.classList.remove('hidden'); djSetlistResults?.classList.add('hidden'); try { const response = await fetch('/api/dj/generate-setlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: tracksData, style: djStyleSelect?.value || 'progressive' }) }); if (!response.ok) throw new Error('Generation failed'); const result = await response.json(); state.lastSetlistResult = result; // Render results renderSetlistResults(result, tracksData); } catch (err) { console.error('Setlist generation error:', err); showToast('Failed to generate setlist'); } finally { djSetlistLoading?.classList.add('hidden'); } }); function renderSetlistResults(result, tracksData) { if (!djOrderedTracks) return; const trackMap = {}; tracksData.forEach(t => trackMap[t.id] = t); state.queue.forEach(t => trackMap[t.id] = { ...trackMap[t.id], ...t }); let html = ''; result.ordered_ids.forEach((id, i) => { const track = trackMap[id]; if (!track) return; // Use embedded audio_features for local tracks, cache for others const isLocal = id.startsWith('local_'); const feat = isLocal ? (track.audio_features || {}) : (state.audioFeaturesCache[id] || {}); const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; html += `
${i + 1}
${escapeHtml(track.name)}
${escapeHtml(track.artists)}
${feat.bpm || '?'} BPM ${feat.camelot || '?'}
`; // Add transition tip if available if (i < result.suggestions?.length) { const sug = result.suggestions[i]; const tipClass = sug.harmonic_match ? 'harmonic' : (sug.bpm_diff > 8 ? 'caution' : ''); const technique = sug.technique ? `${escapeHtml(sug.technique)}` : ''; const timing = sug.timing ? `${escapeHtml(sug.timing)}` : ''; const tipText = sug.tip ? escapeHtml(sug.tip) : ''; html += `
💡 ${technique} ${timing}
${tipText}
`; } }); djOrderedTracks.innerHTML = html; djSetlistResults?.classList.remove('hidden'); djApplyBtn?.classList.remove('hidden'); // Show method used const methodText = result.method === 'ai-gemini-2.0-flash' ? '✨ AI Generated' : '📊 Algorithm'; showToast(`${methodText} setlist ready!`); } // Apply setlist to queue djApplyBtn?.addEventListener('click', () => { if (!state.lastSetlistResult?.ordered_ids) return; const trackMap = {}; state.queue.forEach(t => trackMap[t.id] = t); const newQueue = []; state.lastSetlistResult.ordered_ids.forEach(id => { if (trackMap[id]) newQueue.push(trackMap[id]); }); // Add any tracks not in the result (shouldn't happen but safety) state.queue.forEach(t => { if (!newQueue.find(q => q.id === t.id)) { newQueue.push(t); } }); state.queue = newQueue; state.currentIndex = 0; updateQueueUI(); closeDJSetlistModal(); showToast('Queue reordered! Ready to mix 🎧'); }); // Add "Generate DJ Set" button to queue header const queueHeader = $('.queue-header'); if (queueHeader) { const djBtn = document.createElement('button'); djBtn.className = 'dj-generate-set-btn'; djBtn.innerHTML = '✨ Generate Set'; djBtn.addEventListener('click', openDJSetlistModal); queueHeader.querySelector('.queue-controls')?.prepend(djBtn); } // Modify renderQueueItem to show DJ badges (override/extend existing) const originalUpdateQueue = typeof updateQueueUI !== 'undefined' ? updateQueueUI : null; function updateQueueWithDJ() { originalUpdateQueue?.(); if (state.djMode && state.queue.length > 0) { fetchAudioFeaturesForQueue().then(() => { addDJBadgesToQueue(); // Also update player UI if needed if (state.currentIndex >= 0) { updatePlayerUI(); } }); } } function addDJBadgesToQueue() { if (!state.djMode) return; const queueItems = $$('#queue-container .queue-item'); queueItems.forEach((item, i) => { if (i >= state.queue.length) return; const track = state.queue[i]; const feat = state.audioFeaturesCache[track.id]; // Remove existing badges const existing = item.querySelector('.dj-badge-container'); if (existing) existing.remove(); if (feat) { const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : ''; const badgeContainer = document.createElement('div'); badgeContainer.className = 'dj-badge-container'; badgeContainer.innerHTML = ` ${feat.bpm} BPM ${feat.camelot}
`; item.querySelector('.queue-info')?.appendChild(badgeContainer); } }); } // Escape key closes DJ modal document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !djSetlistModal?.classList.contains('hidden')) { closeDJSetlistModal(); } }); // ========== LOCAL FILE HANDLING ========== function initLocalFiles() { initDragAndDrop(); initManualUpload(); } function initDragAndDrop() { // Attach to window to catch drops anywhere const dropZone = window; ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, preventDefaults, false); }); function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } // Highlight drop zone (using body class) window.addEventListener('dragenter', () => document.body.classList.add('dragging'), false); window.addEventListener('dragleave', (e) => { // Only remove if leaving the window if (e.clientX === 0 && e.clientY === 0) { document.body.classList.remove('dragging'); } }, false); window.addEventListener('drop', (e) => { document.body.classList.remove('dragging'); handleDrop(e); }, false); console.log("Drag & Drop initialized on window"); } function initManualUpload() { // const addLocalBtn = document.getElementById('add-local-btn'); // Replaced by Label const fileInput = document.getElementById('file-input'); if (fileInput) { console.log("Initializing Manual Upload via Label"); // No click listener needed for Label fileInput.addEventListener('change', (e) => { console.log("File Input Changed", e.target.files); if (e.target.files && e.target.files.length > 0) { handleFiles(e.target.files); } }); } else { console.error("Could not find add-local-btn or file-input"); } } function handleDrop(e) { const dt = e.dataTransfer; const files = dt.files; handleFiles(files); } async function handleFiles(files) { // alert(`DEBUG: handleFiles called with ${files.length} files`); console.log("HandleFiles Entry:", files.length); const validExtensions = ['.mp3', '.flac', '.wav', '.aiff', '.aac', '.ogg', '.m4a', '.wma']; const audioFiles = Array.from(files).filter(file => { const isAudio = file.type.startsWith('audio/') || // Fallback: check extension if type is empty or generic validExtensions.some(ext => file.name.toLowerCase().endsWith(ext)); return isAudio; }); // DEBUG: Log files // console.log("All files:", files); if (audioFiles.length === 0) { if (files.length > 0) showToast('No supported audio files found', 'error'); return; } showLoading(`Processing ${audioFiles.length} local files...`); let processedCount = 0; for (const file of audioFiles) { try { const metadata = await extractMetadata(file); if (metadata) { addLocalTrackToQueue(file, metadata); processedCount++; } } catch (err) { console.error('Error processing file:', file.name, err); showToast(`Error reading ${file.name}`, 'error'); } } hideLoading(); if (processedCount > 0) { showToast(`Added ${processedCount} local tracks to queue!`, 'success'); updateQueueUI(); if (!state.isPlaying && state.queue.length === processedCount) { playTrack(0); // Auto play if queue was empty } } } function extractMetadata(file) { return new Promise((resolve) => { if (!window.jsmediatags) { console.warn("jsmediatags not loaded"); resolve({ title: file.name, artist: 'Local File' }); return; } window.jsmediatags.read(file, { onSuccess: (tag) => { const tags = tag.tags; console.log("Raw tags from jsmediatags:", Object.keys(tags)); // DEBUG: Show all tag names let picture = null; if (tags.picture) { const { data, format } = tags.picture; let base64String = ""; for (let i = 0; i < data.length; i++) { base64String += String.fromCharCode(data[i]); } picture = `data:${format};base64,${window.btoa(base64String)}`; } // Helper to extract tag value (handles both direct and .data formats) const getTagValue = (tagName) => { const tag = tags[tagName]; if (!tag) return null; if (typeof tag === 'string' || typeof tag === 'number') return tag; if (tag.data !== undefined) return tag.data; return null; }; // Dynamic search: find any tag containing "bpm" in name let bpm = null; let key = null; for (const tagName of Object.keys(tags)) { const lowerName = tagName.toLowerCase(); const val = getTagValue(tagName); if (!bpm && (lowerName.includes('bpm') || lowerName.includes('beats'))) { bpm = val; console.log(`Found BPM in tag "${tagName}":`, val); } if (!key && (lowerName.includes('key') || lowerName === 'tkey')) { key = val; console.log(`Found Key in tag "${tagName}":`, val); } } // Parse BPM as integer if (bpm) bpm = parseInt(String(bpm).replace(/\D/g, ''), 10) || null; console.log("Final Extracted BPM:", bpm, "Key:", key); // DEBUG resolve({ title: tags.title || file.name, artist: tags.artist || 'Local Artist', album: tags.album || 'Local Album', bpm: bpm, key: key, picture: picture }); }, onError: (error) => { console.warn('Metadata read error:', error); resolve({ title: file.name, artist: 'Local File' }); } }); }); } function addLocalTrackToQueue(file, metadata) { const blobUrl = URL.createObjectURL(file); const safeId = `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Convert musical key to Camelot notation for color coding const camelotKey = musicalKeyToCamelot(metadata.key); const track = { id: safeId, name: metadata.title, artists: metadata.artist, album: metadata.album, album_art: metadata.picture || '/static/icon.svg', duration: 'Unknown', isrc: safeId, audio_features: { bpm: metadata.bpm || 0, camelot: camelotKey || (metadata.bpm ? '?' : null), energy: 0.5, key: -1, mode: 1 }, src: blobUrl, is_local: true }; state.queue.push(track); } // Initialize // Initialize document.addEventListener('DOMContentLoaded', () => { initLocalFiles(); }); // Expose for inline HTML handlers window.handleFiles = handleFiles; window.extractMetadata = extractMetadata; // CSS for drag highlight const style = document.createElement('style'); style.textContent = ` body.dragging { border: 4px dashed #1db954; opacity: 0.8; } `; document.head.appendChild(style); // ========== AI RADIO ========== state.aiRadioActive = false; state.aiRadioFetching = false; state.aiRadioSeedTrack = null; // Store original seed track to prevent genre drift const aiRadioBtn = $('#ai-radio-btn'); let aiRadioStatusEl = null; let aiRadioInterval = null; // Toggle AI Radio if (aiRadioBtn) { aiRadioBtn.addEventListener('click', () => { console.log('AI Radio button clicked!'); state.aiRadioActive = !state.aiRadioActive; aiRadioBtn.classList.toggle('active', state.aiRadioActive); if (state.aiRadioActive) { // Store the original seed track when AI Radio starts const currentTrack = state.queue[Math.max(0, state.currentIndex)]; state.aiRadioSeedTrack = currentTrack ? { name: currentTrack.name, artists: currentTrack.artists, bpm: currentTrack.audio_features?.bpm, camelot: currentTrack.audio_features?.camelot } : null; console.log('AI Radio seed track:', state.aiRadioSeedTrack); showAIRadioStatus('AI Radio Active'); showToast('đŸ“ģ AI Radio started! Will auto-add similar tracks.'); checkAndAddTracks(); // Start immediately // Set up periodic check every 2 minutes (120 seconds) aiRadioInterval = setInterval(() => { console.log('AI Radio periodic check...'); checkAndAddTracks(); }, 120000); } else { hideAIRadioStatus(); showToast('đŸ“ģ AI Radio stopped'); state.aiRadioSeedTrack = null; // Clear seed track // Clear the interval if (aiRadioInterval) { clearInterval(aiRadioInterval); aiRadioInterval = null; } } }); } else { console.error('AI Radio button not found! #ai-radio-btn'); } function showAIRadioStatus(message) { if (!aiRadioStatusEl) { aiRadioStatusEl = document.createElement('div'); aiRadioStatusEl.className = 'ai-radio-status'; document.body.appendChild(aiRadioStatusEl); } aiRadioStatusEl.innerHTML = ` ${message} `; aiRadioStatusEl.style.display = 'flex'; } function hideAIRadioStatus() { if (aiRadioStatusEl) { aiRadioStatusEl.style.display = 'none'; } } async function checkAndAddTracks() { if (!state.aiRadioActive || state.aiRadioFetching) return; const remainingTracks = state.queue.length - Math.max(0, state.currentIndex) - 1; console.log('AI Radio check:', { queueLen: state.queue.length, currentIndex: state.currentIndex, remaining: remainingTracks }); // Add more tracks if we have less than 3 remaining if (remainingTracks < 3) { state.aiRadioFetching = true; showAIRadioStatus('Finding similar tracks...'); try { // Use the ORIGINAL seed track stored when AI Radio started (prevents genre drift) const seed = state.aiRadioSeedTrack; // Get current queue for exclusion const queueTracks = state.queue.map(t => ({ name: t.name, artists: t.artists })); // If no seed, use a default mood const requestBody = { seed_track: seed, mood: seed ? null : "popular music hits", current_queue: queueTracks, count: 5 }; console.log('AI Radio request:', requestBody); const response = await fetch('/api/ai-radio/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (response.ok) { const data = await response.json(); console.log('AI Radio response:', data); const searchTerms = data.search_terms || []; // Search and add tracks let addedCount = 0; for (const term of searchTerms) { if (addedCount >= 3) break; // Limit adds per batch try { const searchRes = await fetch(`/api/search?q=${encodeURIComponent(term)}&type=track`); if (searchRes.ok) { const searchData = await searchRes.json(); const results = searchData.results || []; // Add first non-duplicate result for (const track of results.slice(0, 3)) { const isDupe = state.queue.some(q => q.id === track.id || (q.name?.toLowerCase() === track.name?.toLowerCase() && q.artists?.toLowerCase() === track.artists?.toLowerCase()) ); if (!isDupe) { state.queue.push(track); addedCount++; break; } } } } catch (e) { console.warn('AI Radio search error:', e); } } if (addedCount > 0) { updateQueueUI(); showToast(`đŸ“ģ Added ${addedCount} tracks to queue`); } } } catch (err) { console.error('AI Radio error:', err); } state.aiRadioFetching = false; if (state.aiRadioActive) { showAIRadioStatus('AI Radio Active'); } } } // Check when current track ends const originalPlayTrack = window.playTrack || playTrack; const aiRadioWrappedPlayTrack = async function(index) { await originalPlayTrack(index); if (state.aiRadioActive) { setTimeout(checkAndAddTracks, 1000); } }; // Hook into track end audioPlayer?.addEventListener('ended', () => { if (state.aiRadioActive) { setTimeout(checkAndAddTracks, 500); } }); // alert("DEBUG: App.js initialization COMPLETE. If you see this, script is good."); console.log("App.js initialization COMPLETE"); // ========== ADD TO PLAYLIST MODAL ========== const playlistModal = $('#playlist-modal'); const playlistList = $('#playlist-list'); const newPlaylistInput = $('#new-playlist-input'); const createPlaylistBtn = $('#create-playlist-btn'); const playlistModalClose = $('#playlist-modal-close'); const addToPlaylistBtn = $('#add-to-playlist-btn'); let pendingTrackForPlaylist = null; function openAddToPlaylistModal(track) { if (!track) { showToast('No track selected'); return; } pendingTrackForPlaylist = track; // Render playlist list if (state.playlists.length === 0) { playlistList.innerHTML = '

No playlists yet. Create one below!

'; } else { playlistList.innerHTML = state.playlists.map(p => `
${escapeHtml(p.name)} (${p.tracks.length})
`).join(''); // Click handler for each playlist item playlistList.querySelectorAll('.playlist-list-item').forEach(el => { el.addEventListener('click', () => { addToPlaylist(el.dataset.playlistId, pendingTrackForPlaylist); closeAddToPlaylistModal(); }); }); } playlistModal.classList.remove('hidden'); } function closeAddToPlaylistModal() { playlistModal.classList.add('hidden'); pendingTrackForPlaylist = null; newPlaylistInput.value = ''; } // Create new playlist from modal createPlaylistBtn?.addEventListener('click', () => { const name = newPlaylistInput.value.trim(); if (!name) { showToast('Enter a playlist name'); return; } let tracks = []; if (pendingTrackForPlaylist) { tracks = Array.isArray(pendingTrackForPlaylist) ? pendingTrackForPlaylist : [pendingTrackForPlaylist]; } const newPlaylist = createPlaylist(name, tracks); closeAddToPlaylistModal(); }); // Close modal playlistModalClose?.addEventListener('click', closeAddToPlaylistModal); playlistModal?.addEventListener('click', (e) => { if (e.target === playlistModal) closeAddToPlaylistModal(); }); // Heart button in More Menu -> opens modal for current track addToPlaylistBtn?.addEventListener('click', () => { const currentTrack = state.queue[state.currentIndex]; if (currentTrack) { openAddToPlaylistModal(currentTrack); // Close More menu $('#player-more-menu')?.classList.add('hidden'); } else { showToast('No track playing'); } }); // Expose for queue item hearts (will be wired in updateQueueUI) window.openAddToPlaylistModal = openAddToPlaylistModal; // ========== LOAD MORE RESULTS ========== const loadMoreBtn = $('#load-more-btn'); if (loadMoreBtn) { loadMoreBtn.addEventListener('click', () => { if (state.lastSearchQuery) { performSearch(state.lastSearchQuery, true); } }); } // ========== LISTENBRAINZ LOGIC ========== // Scrobble Logic async function submitNowPlaying(track) { if (!state.listenBrainzConfig.valid) return; try { await fetch('/api/listenbrainz/now-playing', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(track) }); } catch (e) { console.error('Now playing error:', e); } } async function submitScrobble(track) { if (!state.listenBrainzConfig.valid || state.scrobbledCurrent) return; try { state.scrobbledCurrent = true; // Prevent double scrobble await fetch('/api/listenbrainz/scrobble', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(track) }); console.log('Scrobbled:', track.name); } catch (e) { console.error('Scrobble error:', e); } } // Check initial LB status fetch('/api/listenbrainz/validate') .then(res => res.json()) .then(data => { state.listenBrainzConfig = data; if (data.valid) console.log('ListenBrainz connected:', data.username); }) .catch(console.error); async function renderRecommendations() { resultsSection.classList.remove('hidden'); detailView.classList.add('hidden'); queueSection.classList.add('hidden'); let html = ''; // ========== SPOTIFY "MADE FOR YOU" SECTION ========== // DISABLED: Spotify's sp_dc cookie auth doesn't provide personalized search results. // Re-enable when Spotify Developer API access is available with proper OAuth scopes. /* try { const spotifyRes = await fetch('/api/spotify/made-for-you'); if (spotifyRes.ok) { const spotifyPlaylists = await spotifyRes.json(); if (spotifyPlaylists && spotifyPlaylists.length > 0) { html += `

Spotify For You

${spotifyPlaylists.length} playlists
`; for (const playlist of spotifyPlaylists) { html += `
${escapeHtml(playlist.name)} Spotify

${escapeHtml(playlist.name)}

${escapeHtml(playlist.owner || 'Spotify')}

`; } html += '
'; } } } catch (e) { console.warn('Could not load Spotify Made For You:', e); } */ // ========== LISTENBRAINZ SECTION ========== if (!state.listenBrainzConfig.valid) { if (!html) { // No Spotify AND no ListenBrainz - show empty state resultsContainer.innerHTML = `
✨

Connect Spotify or ListenBrainz to see personalized recommendations.

Set SPOTIFY_SP_DC or LISTENBRAINZ_TOKEN in your environment variables.

`; } else { resultsContainer.innerHTML = html; attachSpotifyMFYHandlers(); } return; } showLoading('Loading your ListenBrainz playlists...'); try { // Fetch playlists first const playlistsRes = await fetch(`/api/listenbrainz/playlists/${state.listenBrainzConfig.username}`); const playlistsData = await playlistsRes.json(); hideLoading(); // Fetch and display stats panel try { const statsRes = await fetch(`/api/listenbrainz/stats/${state.listenBrainzConfig.username}`); if (statsRes.ok) { const stats = await statsRes.json(); if (stats.listen_count > 0) { html += `
${stats.listen_count.toLocaleString()} Total Scrobbles
${stats.top_artists.length > 0 ? `
Top This Week
${stats.top_artists.slice(0, 3).map(a => `${escapeHtml(a.name)}`).join('')}
` : ''}
`; } } } catch (e) { console.warn('Could not load LB stats:', e); } // Show playlists section if (playlistsData.playlists && playlistsData.playlists.length > 0) { html += `

ListenBrainz Playlists

${playlistsData.count} playlists
`; for (const playlist of playlistsData.playlists) { html += `
${escapeHtml(playlist.name)} LB

${escapeHtml(playlist.name)}

${escapeHtml(playlist.artists)}

${playlist.total_tracks || '?'} tracks
`; } html += '
'; } else { html += `

ListenBrainz Playlists

📋

No playlists found. Weekly Exploration playlists are generated on Mondays!

`; } resultsContainer.innerHTML = html; // Attach click handlers for playlist cards resultsContainer.querySelectorAll('.lb-playlist-card').forEach(card => { card.addEventListener('click', async () => { const playlistId = card.dataset.id; await openLBPlaylist(playlistId); }); }); attachSpotifyMFYHandlers(); } catch (e) { console.error(e); showError('Failed to load ListenBrainz data'); } } // Attach click handlers for Spotify "Made For You" cards function attachSpotifyMFYHandlers() { resultsContainer.querySelectorAll('.spotify-mfy-card').forEach(card => { card.addEventListener('click', async () => { const playlistId = card.dataset.id; await openSpotifyPlaylist(playlistId); }); }); } // Open a Spotify playlist in detail view async function openSpotifyPlaylist(playlistId) { showLoading('Loading playlist tracks...'); try { const res = await fetch(`/api/content/playlist/${playlistId}?source=spotify`); const playlist = await res.json(); if (!res.ok) throw new Error(playlist.detail); hideLoading(); console.log('Opening Spotify playlist:', playlist.name, playlist); // Show in detail view showDetailView(playlist, playlist.tracks || []); } catch (e) { console.error('Failed to load Spotify playlist:', e); showError('Failed to load playlist'); } } // Open a ListenBrainz playlist in detail view async function openLBPlaylist(playlistId) { showLoading('Loading playlist tracks...'); try { const res = await fetch(`/api/listenbrainz/playlist/${playlistId}`); const playlist = await res.json(); if (!res.ok) throw new Error(playlist.detail); hideLoading(); console.log('Opening LB playlist:', playlist.name, playlist); // Show in detail view showDetailView(playlist, playlist.tracks || []); } catch (e) { console.error('Failed to load LB playlist:', e); showError('Failed to load playlist'); } } function showToast(message, duration = 3000) { let toast = document.createElement('div'); toast.className = 'toast-notification'; toast.textContent = message; Object.assign(toast.style, { position: 'fixed', bottom: '80px', left: '50%', transform: 'translateX(-50%)', backgroundColor: 'var(--accent)', color: 'white', padding: '10px 20px', borderRadius: '20px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: '10000', opacity: '0', transition: 'opacity 0.3s', pointerEvents: 'none' }); document.body.appendChild(toast); // Animate in requestAnimationFrame(() => toast.style.opacity = '1'); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, duration); } // ==================== WINAMP MINI PLAYER ==================== async function toggleMiniPlayer() { if (!('documentPictureInPicture' in window)) { showError('Mini Player not supported in this browser (Chrome/Edge 116+ required)'); return; } if (pipWindow) { pipWindow.close(); pipWindow = null; return; } try { pipWindow = await documentPictureInPicture.requestWindow({ width: 320, height: 160, }); // Copy Styles [...document.styleSheets].forEach((styleSheet) => { try { const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join(''); const style = document.createElement('style'); style.textContent = cssRules; pipWindow.document.head.appendChild(style); } catch (e) { // Ignore CORS errors for external sheets const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = styleSheet.type; link.media = styleSheet.media; link.href = styleSheet.href; pipWindow.document.head.appendChild(link); } }); // Render Winamp HTML updateMiniPlayerDOM(); // Bind Controls const doc = pipWindow.document; doc.getElementById('wa-prev').onclick = () => playPrevious(); doc.getElementById('wa-play').onclick = () => { const p = getActivePlayer(); if (p.paused) p.play(); else p.pause(); updateMiniPlayer(); // Immediate update }; doc.getElementById('wa-pause').onclick = () => getActivePlayer().pause(); doc.getElementById('wa-next').onclick = () => playNext(); doc.getElementById('wa-vol').oninput = (e) => { const val = e.target.value / 100; updateVolume(val); // Syncs main slider too }; // Force initial update updateMiniPlayer(); // Handle Close pipWindow.addEventListener('pagehide', () => { pipWindow = null; }); } catch (err) { console.error('Failed to open Mini Player:', err); } } function updateMiniPlayerDOM() { if (!pipWindow) return; const doc = pipWindow.document; // If body is empty, inject structure if (!doc.body.children.length) { doc.body.className = 'winamp-body'; doc.body.innerHTML = `
FREEDIFY
00:00
Ready to Llama...
MP3 STOP
VOL
`; } } function updateMiniPlayer() { if (!pipWindow) return; const doc = pipWindow.document; const player = getActivePlayer(); // Time const cur = player.currentTime || 0; const mins = Math.floor(cur / 60); const secs = Math.floor(cur % 60); const timeStr = `${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`; const timeEl = doc.getElementById('wa-time'); if (timeEl && timeEl.textContent !== timeStr) timeEl.textContent = timeStr; // Metadata (Title - Artist) const title = $('#player-title').textContent; const artist = $('#player-artist').textContent; const text = `${artist} - ${title}`; const marquee = doc.getElementById('wa-marquee'); if (marquee) { // Update span content or create one if missing let span = marquee.querySelector('span'); if (!span) { span = doc.createElement('span'); marquee.appendChild(span); } if (span.textContent !== text) span.textContent = text; } // State (Play/Pause/Stop) const stateEl = doc.getElementById('wa-state'); const newState = player.paused ? 'PAUSE' : 'PLAY'; if (stateEl && stateEl.textContent !== newState) stateEl.textContent = newState; // Art const mainArt = $('#player-art'); const waArt = doc.getElementById('wa-art'); if (waArt && mainArt && waArt.src !== mainArt.src) waArt.src = mainArt.src; // Format const badge = $('#audio-format-badge'); const waFormat = doc.getElementById('wa-format'); if (waFormat && badge) { waFormat.textContent = badge.textContent || 'MP3'; waFormat.style.color = (badge.textContent === 'HiFi' || badge.textContent === 'FLAC') ? '#00e000' : '#c0c000'; } } // ==================== AI ASSISTANT MODAL ==================== const aiModal = document.getElementById('ai-modal'); const aiModalClose = document.getElementById('ai-modal-close'); const aiModalOverlay = aiModal?.querySelector('.ai-modal-overlay'); const aiMenuBtn = document.getElementById('ai-menu-btn'); // Playlist Generator elements const aiPlaylistInput = document.getElementById('ai-playlist-input'); const aiPlaylistGenBtn = document.getElementById('ai-playlist-gen-btn'); const aiPlaylistResults = document.getElementById('ai-playlist-results'); const aiDurationSlider = document.getElementById('ai-duration-slider'); const aiDurationLabel = document.getElementById('ai-duration-label'); // Open/Close Modal function openAIModal() { if (aiModal) { aiModal.classList.remove('hidden'); aiPlaylistInput?.focus(); // Hide menu if open document.getElementById('search-more-menu')?.classList.add('hidden'); } } function closeAIModal() { if (aiModal) { aiModal.classList.add('hidden'); } } // Event listeners aiMenuBtn?.addEventListener('click', openAIModal); aiModalClose?.addEventListener('click', closeAIModal); aiModalOverlay?.addEventListener('click', closeAIModal); // Duration slider aiDurationSlider?.addEventListener('input', () => { if (aiDurationLabel) { aiDurationLabel.textContent = `${aiDurationSlider.value} min`; } }); // Playlist Generator aiPlaylistGenBtn?.addEventListener('click', async () => { const description = aiPlaylistInput?.value?.trim(); if (!description) return; const duration = parseInt(aiDurationSlider?.value) || 60; aiPlaylistGenBtn.disabled = true; aiPlaylistResults.innerHTML = '
Generating playlist
'; try { const res = await fetch('/api/ai/generate-playlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description, duration_mins: duration }) }); const data = await res.json(); if (data.tracks && data.tracks.length > 0) { let html = `
đŸŽĩ ${data.playlist_name || 'Generated Playlist'} ${data.tracks.length} tracks
`; data.tracks.forEach((track, i) => { html += `
${i + 1}
${escapeHtml(track.title)}
${escapeHtml(track.artist)}
`; }); html += ''; aiPlaylistResults.innerHTML = html; // Click handler for individual tracks aiPlaylistResults.querySelectorAll('.ai-track-item').forEach(item => { item.addEventListener('click', async () => { const searchQuery = `${item.dataset.artist} ${item.dataset.title}`; closeAIModal(); searchInput.value = searchQuery; await performSearch(searchQuery); }); }); // Add all button document.getElementById('ai-add-all')?.addEventListener('click', async () => { closeAIModal(); const wasEmpty = state.queue.length === 0; // Auto-play if queue was empty OR nothing is currently loaded/playing const shouldAutoPlay = wasEmpty || !state.currentTrack; const tracks = data.tracks; if (tracks.length === 0) return; // Helper to search for a track const searchTrack = async (track) => { try { const searchRes = await fetch(`/api/search?q=${encodeURIComponent(track.artist + ' ' + track.title)}&type=track&limit=1`); const searchData = await searchRes.json(); if (searchData.results && searchData.results.length > 0) { return searchData.results[0]; } } catch (e) { console.error('Failed to find track:', track.title); } return null; }; let hasStartedPlaying = false; // 1. Process FIRST track immediately for instant playback const firstTrack = tracks[0]; const firstResult = await searchTrack(firstTrack); if (firstResult) { addToQueue(firstResult); if (shouldAutoPlay) { playTrack(firstResult); hasStartedPlaying = true; } } // 2. Process REST in parallel to preserve order but run fast if (tracks.length > 1) { const restTracks = tracks.slice(1); // Fetch all in parallel // Note: If list is huge (e.g. 50+), we might want to batch this. // But for typical AI playlists (10-20), Promise.all is fine. const results = await Promise.all(restTracks.map(t => searchTrack(t))); // Add valid results to queue in order let addedCount = 0; results.forEach(result => { if (result) { addToQueue(result); addedCount++; // Fallback: If first track failed to play/find, play the first valid one we found here if (shouldAutoPlay && !hasStartedPlaying) { playTrack(result); hasStartedPlaying = true; } } }); if (addedCount > 0) { showToast(`Added ${addedCount + (firstResult ? 1 : 0)} tracks to queue`); } } }); } else { aiPlaylistResults.innerHTML = '

Could not generate playlist. Try a different description.

'; } } catch (e) { console.error('Playlist generation error:', e); aiPlaylistResults.innerHTML = '

Playlist generation failed. Please try again.

'; } finally { aiPlaylistGenBtn.disabled = false; } }); // Close on Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && aiModal && !aiModal.classList.contains('hidden')) { closeAIModal(); } }); console.log('AI Assistant module loaded'); // ========== KEYBOARD SHORTCUTS ========== // Use existing shortcuts modal const shortcutsModal = $('#shortcuts-help'); const shortcutsCloseBtn = $('#shortcuts-close'); function openShortcutsModal() { if (shortcutsModal) shortcutsModal.classList.remove('hidden'); } function closeShortcutsModal() { if (shortcutsModal) shortcutsModal.classList.add('hidden'); } if (shortcutsCloseBtn) { shortcutsCloseBtn.addEventListener('click', closeShortcutsModal); } // Close on backdrop click if (shortcutsModal) { shortcutsModal.addEventListener('click', (e) => { if (e.target === shortcutsModal) closeShortcutsModal(); }); } // Global keyboard shortcuts document.addEventListener('keydown', (e) => { // Ignore if typing in input const target = e.target; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } // Check for modals first if (e.key === 'Escape') { if (shortcutsModal && !shortcutsModal.classList.contains('hidden')) { closeShortcutsModal(); return; } // Other escape handlers exist in their own listeners } // ? - Show shortcuts help if (e.key === '?' || (e.shiftKey && e.key === '/')) { e.preventDefault(); openShortcutsModal(); return; } // / - Focus search if (e.key === '/' && !e.shiftKey) { e.preventDefault(); if (searchInput) searchInput.focus(); return; } // Space - Play/Pause if (e.key === ' ') { e.preventDefault(); togglePlay(); return; } // Arrow Left - Previous track if (e.key === 'ArrowLeft' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); playPrevious(); return; } // Arrow Right - Next track if (e.key === 'ArrowRight' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); playNext(); return; } // Arrow Up - Volume up if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); const newVol = Math.min(1, state.volume + 0.1); setVolume(newVol); return; } // Arrow Down - Volume down if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); const newVol = Math.max(0, state.volume - 0.1); setVolume(newVol); return; } // M - Mute/Unmute if (e.key.toLowerCase() === 'm') { toggleMute(); return; } // R - Toggle repeat if (e.key.toLowerCase() === 'r') { cycleRepeatMode(); return; } // S - Shuffle (if in queue view) (no Shift) if (e.key.toLowerCase() === 's' && !e.shiftKey) { shuffleQueue(); return; } // Shift + S - Sync to Drive if (e.key.toLowerCase() === 's' && e.shiftKey) { e.preventDefault(); syncBtn?.click(); return; } // F - Toggle fullscreen player if (e.key.toLowerCase() === 'f') { toggleFullscreenPlayer(); return; } // Q - Toggle queue if (e.key.toLowerCase() === 'q') { toggleQueue(); return; } // E - Toggle EQ panel if (e.key.toLowerCase() === 'e') { eqToggleBtn?.click(); return; } // P - Add current track to Playlist if (e.key.toLowerCase() === 'p') { // Yield to visualizer controls if (visualizerActive && visualizerMode === 'milkdrop') return; const currentTrack = state.queue[state.currentIndex]; if (currentTrack && window.openAddToPlaylistModal) { window.openAddToPlaylistModal(currentTrack); } else { showToast('No track playing'); } return; } // H - Toggle HiFi / Hi-Res mode if (e.key.toLowerCase() === 'h') { hifiBtn?.click(); return; } // D - Download current track if (e.key.toLowerCase() === 'd') { downloadCurrentTrack(); return; } // A - Toggle AI Radio if (e.key.toLowerCase() === 'a') { aiRadioBtn?.click(); return; } }); console.log('Keyboard shortcuts loaded'); // ========== INIT: Load persisted state ========== // Load saved queue on startup setTimeout(() => { loadQueueFromStorage(); // Apply saved volume to audio players AND slider UI audioPlayer.volume = state.volume; audioPlayer2.volume = state.volume; if (volumeSlider) { volumeSlider.value = Math.round(state.volume * 100); } console.log(`Volume restored: ${Math.round(state.volume * 100)}%`); }, 100); // ========== LYRICS MODAL ========== const lyricsBtn = $('#lyrics-btn'); const fsLyricsBtn = $('#fs-lyrics-btn'); const lyricsModal = $('#lyrics-modal'); const lyricsModalClose = $('#lyrics-modal-close'); const lyricsModalArt = $('#lyrics-modal-art'); const lyricsModalTitle = $('#lyrics-modal-title'); const lyricsModalArtist = $('#lyrics-modal-artist'); const lyricsModalAlbum = $('#lyrics-modal-album'); const lyricsLoading = $('#lyrics-loading'); const lyricsText = $('#lyrics-text'); const lyricsNotFound = $('#lyrics-not-found'); const lyricsSearchLink = $('#lyrics-search-link'); const aboutDescription = $('#about-description'); const aboutRelease = $('#about-release'); const aboutWriters = $('#about-writers'); const aboutProducers = $('#about-producers'); const geniusLink = $('#genius-link'); const annotationsList = $('#annotations-list'); const annotationsEmpty = $('#annotations-empty'); const lyricsTabs = document.querySelectorAll('.lyrics-tab'); const lyricsPanels = document.querySelectorAll('.lyrics-panel'); let currentLyricsData = null; async function openLyricsModal() { const track = state.queue[state.currentIndex]; if (!track) { showToast('No track playing'); return; } // Show modal and loading state lyricsModal.classList.remove('hidden'); lyricsLoading.classList.remove('hidden'); lyricsText.textContent = ''; lyricsNotFound.classList.add('hidden'); aboutDescription.textContent = ''; aboutRelease.textContent = ''; aboutWriters.textContent = ''; aboutProducers.textContent = ''; // Set header info lyricsModalArt.src = track.album_art || '/static/icon.svg'; lyricsModalTitle.textContent = track.name || 'Unknown'; lyricsModalArtist.textContent = track.artists || 'Unknown Artist'; lyricsModalAlbum.textContent = track.album || ''; // Reset to lyrics tab lyricsTabs.forEach(t => t.classList.remove('active')); lyricsPanels.forEach(p => p.classList.remove('active')); document.querySelector('[data-tab="lyrics"]')?.classList.add('active'); document.getElementById('lyrics-panel')?.classList.add('active'); // Fetch lyrics try { const artist = track.artists || ''; const title = track.name || ''; const response = await fetch(`/api/lyrics?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); const data = await response.json(); currentLyricsData = data; lyricsLoading.classList.add('hidden'); if (data.found && data.lyrics) { lyricsText.textContent = data.lyrics; lyricsNotFound.classList.add('hidden'); } else { lyricsText.textContent = ''; lyricsNotFound.classList.remove('hidden'); lyricsSearchLink.href = `https://genius.com/search?q=${encodeURIComponent(artist + ' ' + title)}`; } // Populate About tab if (data.about) { aboutDescription.textContent = data.about; } else { aboutDescription.textContent = 'No description available for this track.'; } if (data.release_date) { aboutRelease.innerHTML = `Released: ${data.release_date}`; } if (data.writers && data.writers.length > 0) { aboutWriters.innerHTML = `Written by: ${data.writers.join(', ')}`; } if (data.producers && data.producers.length > 0) { aboutProducers.innerHTML = `Produced by: ${data.producers.join(', ')}`; } if (data.genius_url) { geniusLink.href = data.genius_url; geniusLink.classList.remove('hidden'); } else { geniusLink.classList.add('hidden'); } // Populate Annotations tab if (data.annotations && data.annotations.length > 0) { annotationsList.innerHTML = data.annotations.map(ann => `
"${ann.fragment}"
${ann.text}
`).join(''); annotationsEmpty.classList.add('hidden'); } else { annotationsList.innerHTML = ''; annotationsEmpty.classList.remove('hidden'); } } catch (error) { console.error('Lyrics fetch error:', error); lyricsLoading.classList.add('hidden'); lyricsText.textContent = ''; lyricsNotFound.classList.remove('hidden'); const artist = track.artists || ''; const title = track.name || ''; lyricsSearchLink.href = `https://genius.com/search?q=${encodeURIComponent(artist + ' ' + title)}`; } } function closeLyricsModal() { lyricsModal.classList.add('hidden'); currentLyricsData = null; } // Button handlers if (lyricsBtn) { lyricsBtn.addEventListener('click', openLyricsModal); } if (fsLyricsBtn) { fsLyricsBtn.addEventListener('click', openLyricsModal); } if (lyricsModalClose) { lyricsModalClose.addEventListener('click', closeLyricsModal); } // Close on backdrop click lyricsModal?.addEventListener('click', (e) => { if (e.target === lyricsModal) { closeLyricsModal(); } }); // Tab switching lyricsTabs.forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; lyricsTabs.forEach(t => t.classList.remove('active')); lyricsPanels.forEach(p => p.classList.remove('active')); tab.classList.add('active'); document.getElementById(`${tabName}-panel`)?.classList.add('active'); }); }); // Add L keyboard shortcut for lyrics document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key.toLowerCase() === 'l' && !e.ctrlKey && !e.metaKey) { openLyricsModal(); } }); console.log('Lyrics modal loaded'); // ========== MUSIC VIDEO ========== const fsVideoBtn = $('#fs-video-btn'); function openMusicVideo() { const track = state.queue[state.currentIndex]; if (!track) { showToast('No track playing'); return; } const artist = track.artists || ''; const title = track.name || ''; const query = `${artist} ${title} official music video`; // Open YouTube search in new tab const youtubeUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; window.open(youtubeUrl, '_blank'); showToast('đŸŽŦ Opening YouTube...'); } // Button handlers if (fsVideoBtn) { fsVideoBtn.addEventListener('click', openMusicVideo); } // Also add to more menu video button const videoBtn = $('#video-btn'); if (videoBtn) { videoBtn.addEventListener('click', openMusicVideo); } // V keyboard shortcut for video document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key.toLowerCase() === 'v' && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.shiftKey) { e.preventDefault(); openVisualizer(); } else { openMusicVideo(); } } }); console.log('Music video feature loaded'); // Concerts feature removed per user request // ========== AUDIO VISUALIZER ========== const visualizerBtn = $('#fs-visualizer-btn'); const visualizerOverlay = $('#visualizer-overlay'); const visualizerCanvas = $('#visualizer-canvas'); const visualizerCanvasWebgl = $('#visualizer-canvas-webgl'); const visualizerClose = $('#visualizer-close'); const vizTrackName = $('#viz-track-name'); const vizTrackArtist = $('#viz-track-artist'); const vizModeBtns = document.querySelectorAll('.viz-mode-btn'); let visualizerActive = false; let visualizerMode = 'bars'; let vizAnalyser = null; let animationId = null; let particles = []; // Butterchurn (MilkDrop) variables let butterchurnVisualizer = null; let butterchurnPresets = []; let butterchurnPresetNames = []; let currentPresetIndex = 0; function initButterchurn() { const bc = window.butterchurn?.default || window.butterchurn; if (butterchurnVisualizer || !bc) { if (!bc) console.error('Butterchurn library not found on window object'); return null; } try { const canvas = visualizerCanvasWebgl || visualizerCanvas; // Fallback if element missing butterchurnVisualizer = bc.createVisualizer( audioContext, canvas, { width: canvas.width, height: canvas.height, pixelRatio: window.devicePixelRatio || 1, textureRatio: 1 } ); // Load presets let presets = window.butterchurnPresets?.default || window.butterchurnPresets; if (presets) { // Check if it's a module with getPresets if (typeof presets.getPresets === 'function') { presets = presets.getPresets(); } butterchurnPresets = presets; butterchurnPresetNames = Object.keys(butterchurnPresets); console.log(`Loaded ${butterchurnPresetNames.length} MilkDrop presets`); // Load a random preset to start if (butterchurnPresetNames.length > 0) { currentPresetIndex = Math.floor(Math.random() * butterchurnPresetNames.length); loadButterchurnPreset(currentPresetIndex); } } // Connect to audio butterchurnVisualizer.connectAudio(vizAnalyser || volumeBoostGain); console.log('Butterchurn initialized'); return butterchurnVisualizer; } catch (e) { console.error('Failed to init Butterchurn:', e); return null; } } function loadButterchurnPreset(index) { if (!butterchurnVisualizer || butterchurnPresetNames.length === 0) return; // Ensure index is valid if (index < 0) index = butterchurnPresetNames.length - 1; if (index >= butterchurnPresetNames.length) index = 0; currentPresetIndex = index; const presetName = butterchurnPresetNames[index]; const preset = butterchurnPresets[presetName]; console.log(`Loading preset [${index}]: ${presetName}`, preset ? 'found' : 'missing'); if (preset) { try { butterchurnVisualizer.loadPreset(preset, 1.0); // 1.0 = blend time showToast(`🎆 ${presetName}`); } catch (err) { console.error('Error loading preset:', err); } } } function nextButterchurnPreset() { console.log('Next preset clicked'); loadButterchurnPreset(currentPresetIndex + 1); } function prevButterchurnPreset() { console.log('Prev preset clicked'); loadButterchurnPreset(currentPresetIndex - 1); } function randomButterchurnPreset() { currentPresetIndex = Math.floor(Math.random() * butterchurnPresetNames.length); loadButterchurnPreset(currentPresetIndex); } function initVisualizerAnalyser() { if (vizAnalyser) return; // We need to use the existing audioContext from the equalizer // First ensure EQ is initialized (which creates the audioContext) if (!audioContext) { initEqualizer(); } if (!audioContext) { console.error('No audio context available for visualizer'); return; } try { // Create analyser and connect it to the audio chain vizAnalyser = audioContext.createAnalyser(); vizAnalyser.fftSize = 256; vizAnalyser.smoothingTimeConstant = 0.8; // Connect the volumeBoostGain to the analyser, then analyser to destination // We need to disconnect volumeBoostGain from destination first // Actually, let's just connect analyser in parallel to monitor the output if (volumeBoostGain) { volumeBoostGain.connect(vizAnalyser); } else { // If no EQ chain, try direct connection (fallback) console.warn('No volumeBoostGain, visualizer may not work well'); } console.log('Visualizer analyser connected to audio chain'); } catch (e) { console.error('Failed to init visualizer analyser:', e); } } function drawBars(ctx, dataArray, width, height) { const barCount = 64; const barWidth = width / barCount - 2; const gradient = ctx.createLinearGradient(0, height, 0, 0); gradient.addColorStop(0, '#ec4899'); gradient.addColorStop(0.5, '#f59e0b'); gradient.addColorStop(1, '#10b981'); for (let i = 0; i < barCount; i++) { const barHeight = (dataArray[i] / 255) * height * 0.8; const x = i * (barWidth + 2); ctx.fillStyle = gradient; ctx.fillRect(x, height - barHeight, barWidth, barHeight); // Mirror reflection ctx.globalAlpha = 0.3; ctx.fillRect(x, height, barWidth, barHeight * 0.3); ctx.globalAlpha = 1; } } function drawWave(ctx, dataArray, width, height) { ctx.beginPath(); ctx.strokeStyle = '#ec4899'; ctx.lineWidth = 3; const sliceWidth = width / dataArray.length; let x = 0; for (let i = 0; i < dataArray.length; i++) { const v = dataArray[i] / 255; const y = v * height; if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } x += sliceWidth; } ctx.stroke(); // Draw mirrored wave ctx.beginPath(); ctx.strokeStyle = '#f59e0b'; ctx.globalAlpha = 0.5; x = 0; for (let i = 0; i < dataArray.length; i++) { const v = dataArray[i] / 255; const y = height - (v * height); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); x += sliceWidth; } ctx.stroke(); ctx.globalAlpha = 1; } function drawParticles(ctx, dataArray, width, height) { // Spawn new particles based on audio intensity const avgIntensity = dataArray.reduce((a, b) => a + b, 0) / dataArray.length; if (avgIntensity > 100 && particles.length < 200) { for (let i = 0; i < 3; i++) { particles.push({ x: Math.random() * width, y: height + 10, vx: (Math.random() - 0.5) * 4, vy: -(Math.random() * 5 + 2), size: Math.random() * 6 + 2, color: `hsl(${Math.random() * 60 + 300}, 100%, 60%)`, life: 1 }); } } // Update and draw particles particles = particles.filter(p => p.life > 0); for (const p of particles) { p.x += p.vx; p.y += p.vy; p.vy += 0.02; // Gravity p.life -= 0.01; ctx.beginPath(); const radius = Math.max(0, p.size * p.life); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.fillStyle = p.color; ctx.globalAlpha = p.life; ctx.fill(); } ctx.globalAlpha = 1; } function renderVisualizer() { if (!visualizerActive || !vizAnalyser) return; const canvas = visualizerCanvas; const ctx = canvas.getContext('2d'); const width = canvas.width; const height = canvas.height; // Get frequency data const bufferLength = vizAnalyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); vizAnalyser.getByteFrequencyData(dataArray); // Clear canvas with fade effect ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; ctx.fillRect(0, 0, width, height); // Draw based on mode switch (visualizerMode) { case 'milkdrop': // Butterchurn handles its own rendering if (butterchurnVisualizer) { butterchurnVisualizer.render(); } break; case 'bars': drawBars(ctx, dataArray, width, height); break; case 'wave': drawWave(ctx, dataArray, width, height); break; case 'particles': drawParticles(ctx, dataArray, width, height); break; } animationId = requestAnimationFrame(renderVisualizer); } // Visualizer Idle State let visualizerIdleTimer = null; let vizInfoBriefTimer = null; let visualizerListenersAttached = false; function resetVisualizerIdleTimer() { if (!visualizerActive) return; // Remove idle class (show UI) visualizerOverlay.classList.remove('user-idle'); // Clear existing timer if (visualizerIdleTimer) clearTimeout(visualizerIdleTimer); // Set new timer (10s) visualizerIdleTimer = setTimeout(() => { if (visualizerActive) { visualizerOverlay.classList.add('user-idle'); } }, 10000); } function showVisualizerInfoBriefly() { if (!visualizerActive) return; // Ensure info is updated const track = state.queue[state.currentIndex]; if (track) { vizTrackName.textContent = track.name || 'Unknown Track'; vizTrackArtist.textContent = track.artists || ''; } // Add temp-visible class const info = document.querySelector('.visualizer-track-info'); if (info) { info.classList.add('temp-visible'); if (vizInfoBriefTimer) clearTimeout(vizInfoBriefTimer); vizInfoBriefTimer = setTimeout(() => { info.classList.remove('temp-visible'); }, 15000); // 15s } } function initVisualizerIdleState() { if (visualizerListenersAttached) return; const events = ['mousemove', 'mousedown', 'click', 'keydown', 'touchstart']; events.forEach(event => { document.addEventListener(event, resetVisualizerIdleTimer); }); visualizerListenersAttached = true; resetVisualizerIdleTimer(); } function openVisualizer() { const track = state.queue[state.currentIndex]; if (!track) { showToast('Play a track first'); return; } // Initialize visualizer analyser (uses existing audioContext from EQ) initVisualizerAnalyser(); if (audioContext?.state === 'suspended') { audioContext.resume(); } // Init idle state initVisualizerIdleState(); visualizerOverlay.classList.remove('user-idle'); // Update track info vizTrackName.textContent = track.name || 'Unknown Track'; vizTrackArtist.textContent = track.artists || ''; // Set canvas size visualizerCanvas.width = window.innerWidth; visualizerCanvas.height = window.innerHeight; if (visualizerCanvasWebgl) { visualizerCanvasWebgl.width = window.innerWidth; visualizerCanvasWebgl.height = window.innerHeight; } // Initial visibility if (visualizerMode === 'milkdrop') { if (!butterchurnVisualizer) initButterchurn(); visualizerCanvasWebgl?.classList.remove('hidden'); visualizerCanvas?.classList.add('hidden'); } else { visualizerCanvasWebgl?.classList.add('hidden'); visualizerCanvas?.classList.remove('hidden'); } // Show overlay visualizerOverlay.classList.remove('hidden'); visualizerActive = true; // Start rendering renderVisualizer(); } function closeVisualizer() { visualizerActive = false; visualizerOverlay.classList.add('hidden'); if (animationId) { cancelAnimationFrame(animationId); animationId = null; } } // Button handlers if (visualizerBtn) { visualizerBtn.addEventListener('click', openVisualizer); } // Also add to more menu visualizer button const menuVisualizerBtn = $('#menu-visualizer-btn'); if (menuVisualizerBtn) { menuVisualizerBtn.addEventListener('click', openVisualizer); } if (visualizerClose) { visualizerClose.addEventListener('click', closeVisualizer); } // Close on ESC document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && visualizerActive) { closeVisualizer(); } // N for Next Preset (MilkDrop) if ((e.key === 'n' || e.key === 'N') && visualizerActive && visualizerMode === 'milkdrop') { nextButterchurnPreset(); } // P for Prev Preset (MilkDrop) if ((e.key === 'p' || e.key === 'P') && visualizerActive && visualizerMode === 'milkdrop') { prevButterchurnPreset(); } }); // Mode switching vizModeBtns.forEach(btn => { btn.addEventListener('click', () => { // Toggle preset button visibility const isMilkDrop = btn.dataset.mode === 'milkdrop'; const nextPresetBtn = document.getElementById('viz-next-preset'); const prevPresetBtn = document.getElementById('viz-prev-preset'); if (nextPresetBtn) nextPresetBtn.style.display = isMilkDrop ? 'block' : 'none'; if (prevPresetBtn) prevPresetBtn.style.display = isMilkDrop ? 'block' : 'none'; // Handle normal mode switching if (!btn.id || (btn.id !== 'viz-next-preset' && btn.id !== 'viz-prev-preset')) { vizModeBtns.forEach(b => { if (b.id !== 'viz-next-preset' && b.id !== 'viz-prev-preset') b.classList.remove('active'); }); btn.classList.add('active'); visualizerMode = btn.dataset.mode; particles = []; // Clear particles when switching modes // Init Butterchurn if needed if (visualizerMode === 'milkdrop') { if (!butterchurnVisualizer) initButterchurn(); // Toggle canvases if (visualizerCanvasWebgl) { visualizerCanvasWebgl.classList.remove('hidden'); visualizerCanvas.classList.add('hidden'); } } else { // Toggle canvases if (visualizerCanvasWebgl) { visualizerCanvasWebgl.classList.add('hidden'); visualizerCanvas.classList.remove('hidden'); } } } }); }); const vizNextPresetBtn = document.getElementById('viz-next-preset'); if (vizNextPresetBtn) { vizNextPresetBtn.addEventListener('click', (e) => { e.stopPropagation(); nextButterchurnPreset(); }); } const vizPrevPresetBtn = document.getElementById('viz-prev-preset'); if (vizPrevPresetBtn) { vizPrevPresetBtn.addEventListener('click', (e) => { e.stopPropagation(); prevButterchurnPreset(); }); } // Handle window resize window.addEventListener('resize', () => { if (visualizerActive) { visualizerCanvas.width = window.innerWidth; visualizerCanvas.height = window.innerHeight; if (visualizerCanvasWebgl) { visualizerCanvasWebgl.width = window.innerWidth; visualizerCanvasWebgl.height = window.innerHeight; } if (butterchurnVisualizer) { butterchurnVisualizer.setRendererSize(window.innerWidth, window.innerHeight); } } }); console.log('Audio visualizer loaded'); // ========== CONCERT ALERTS ========== const concertModal = $('#concert-modal'); const concertModalClose = $('#concert-modal-close'); const concertMenuBtn = $('#concert-search-menu-btn'); const concertResults = $('#concert-results'); const concertLoading = $('#concert-loading'); const concertEmpty = $('#concert-empty'); const concertArtistSearch = $('#concert-artist-search'); const concertSearchBtn = $('#concert-search-btn'); const concertTabs = $$('.concert-tab'); const concertRecentSection = $('#concert-recent-section'); const concertSearchSection = $('#concert-search-section'); // Concert State const concertState = { currentTab: 'recent' }; // Open Concert Modal (optionally with artist pre-filled from main search) function openConcertModal(artistQuery = null) { concertModal?.classList.remove('hidden'); // If artist query provided, switch to search tab and auto-search if (artistQuery && artistQuery.trim()) { concertState.currentTab = 'search'; concertTabs.forEach(t => t.classList.remove('active')); concertTabs.forEach(t => { if (t.dataset.tab === 'search') t.classList.add('active'); }); concertRecentSection?.classList.add('hidden'); concertSearchSection?.classList.remove('hidden'); if (concertArtistSearch) { concertArtistSearch.value = artistQuery.trim(); } searchConcerts(artistQuery.trim()); } else if (concertState.currentTab === 'recent') { // Load concerts for recent artists by default loadConcertsForRecentArtists(); } } // Close Concert Modal function closeConcertModal() { concertModal?.classList.add('hidden'); } // Get unique artists from recent listen history function getRecentArtists() { const artistSet = new Set(); const artists = []; // Get from current queue state.queue.forEach(track => { if (track.artists && !artistSet.has(track.artists)) { artistSet.add(track.artists); artists.push(track.artists.split(',')[0].trim()); // Take first artist } }); // Limit to 10 unique artists return artists.slice(0, 10); } // Load concerts for recent artists async function loadConcertsForRecentArtists() { const artists = getRecentArtists(); if (artists.length === 0) { concertResults.innerHTML = ''; concertEmpty.classList.remove('hidden'); concertEmpty.querySelector('p').textContent = 'Listen to some music first to see concert recommendations!'; return; } concertLoading.classList.remove('hidden'); concertEmpty.classList.add('hidden'); concertResults.innerHTML = ''; try { const response = await fetch(`/api/concerts/for-artists?artists=${encodeURIComponent(artists.join(','))}`); const data = await response.json(); concertLoading.classList.add('hidden'); if (data.events && data.events.length > 0) { renderConcertCards(data.events); } else { concertEmpty.classList.remove('hidden'); concertEmpty.querySelector('p').textContent = 'No upcoming concerts found for your recent artists'; } } catch (error) { console.error('Concert fetch error:', error); concertLoading.classList.add('hidden'); concertEmpty.classList.remove('hidden'); concertEmpty.querySelector('p').textContent = 'Failed to load concerts. Check API keys.'; } } // Search concerts for a specific artist async function searchConcerts(artist) { if (!artist.trim()) return; concertLoading.classList.remove('hidden'); concertEmpty.classList.add('hidden'); concertResults.innerHTML = ''; try { const response = await fetch(`/api/concerts/search?artist=${encodeURIComponent(artist)}`); const data = await response.json(); concertLoading.classList.add('hidden'); if (data.events && data.events.length > 0) { renderConcertCards(data.events); } else { concertEmpty.classList.remove('hidden'); concertEmpty.querySelector('p').textContent = `No upcoming concerts found for "${artist}"`; } } catch (error) { console.error('Concert search error:', error); concertLoading.classList.add('hidden'); concertEmpty.classList.remove('hidden'); concertEmpty.querySelector('p').textContent = 'Search failed. Check API keys.'; } } // Render concert cards function renderConcertCards(events) { concertResults.innerHTML = events.map(event => { const date = event.date ? new Date(event.date + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) : 'TBA'; const time = event.time ? formatConcertTime(event.time) : ''; const location = [event.city, event.state, event.country].filter(Boolean).join(', '); const priceRange = event.price_min && event.price_max ? `$${Math.round(event.price_min)} - $${Math.round(event.price_max)}` : event.price_min ? `From $${Math.round(event.price_min)}` : ''; return `
${event.image ? `${event.artist}` : '
đŸŽĩ
' }
${event.artist || event.name} ${event.source}
📍 ${event.venue}${location ? `, ${location}` : ''}
📅 ${date}${time ? ` â€ĸ ${time}` : ''}
${priceRange ? `
💰 ${priceRange}
` : ''}
${event.ticket_url ? `đŸŽĢ Tickets` : '' }
`; }).join(''); } // Format time from HH:MM:SS to readable function formatConcertTime(timeStr) { if (!timeStr) return ''; const [hours, minutes] = timeStr.split(':'); const h = parseInt(hours); const ampm = h >= 12 ? 'PM' : 'AM'; const hour12 = h % 12 || 12; return `${hour12}:${minutes} ${ampm}`; } // Event Listeners concertMenuBtn?.addEventListener('click', () => { // Get text from main search input if any const mainSearchInput = $('#search-input'); const artistQuery = mainSearchInput?.value || ''; openConcertModal(artistQuery); // Close the more menu $('#search-more-menu')?.classList.add('hidden'); }); concertModalClose?.addEventListener('click', closeConcertModal); concertModal?.addEventListener('click', (e) => { if (e.target === concertModal) closeConcertModal(); }); // Tab switching concertTabs.forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; concertState.currentTab = tabName; // Update active tab concertTabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); // Show/hide sections if (tabName === 'recent') { concertRecentSection.classList.remove('hidden'); concertSearchSection.classList.add('hidden'); loadConcertsForRecentArtists(); } else { concertRecentSection.classList.add('hidden'); concertSearchSection.classList.remove('hidden'); concertResults.innerHTML = ''; concertEmpty.classList.add('hidden'); } }); }); // Search button concertSearchBtn?.addEventListener('click', () => { searchConcerts(concertArtistSearch?.value || ''); }); // Search on Enter concertArtistSearch?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { searchConcerts(concertArtistSearch.value); } }); console.log('Concert alerts loaded');