freedify/static/app.js

6537 lines
226 KiB
JavaScript
Raw Permalink Normal View History

2026-01-13 22:26:48 +00:00
/**
* 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 = `
<div class="empty-state">
<span class="empty-icon"></span>
<p>No saved playlists yet</p>
<p style="font-size: 0.9em; opacity: 0.7;">Import a Spotify playlist and click "Save to Playlist"</p>
</div>
`;
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 += `
<div class="album-item playlist-item" data-playlist-id="${playlist.id}">
<div class="album-art-container">
<img src="${coverArt}" alt="${playlist.name}" class="album-art" loading="lazy">
<div class="album-overlay">
<button class="play-album-btn"></button>
</div>
</div>
<div class="album-info">
<div class="album-name">${playlist.name}</div>
<div class="album-artist">${trackCount} track${trackCount !== 1 ? 's' : ''}</div>
</div>
<button class="delete-playlist-btn" title="Delete playlist">🗑</button>
</div>
`;
});
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 = `
<div class="empty-state">
<span class="empty-icon">🔍</span>
<p>No results found</p>
</div>
`;
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 `
<div class="track-item" data-id="${track.id}">
<img class="track-album-art" src="${track.album_art || '/static/icon.svg'}" alt="${escapeHtml(track.name)}" loading="lazy">
<div class="track-info">
<div class="track-name">${escapeHtml(track.name)}</div>
<div class="track-artist">${escapeHtml(track.artists)}</div>
</div>
<span class="track-duration">${track.duration_ms ? formatTime(track.duration_ms / 1000) : (track.duration && track.duration.toString().includes(':') ? track.duration : formatTime(track.duration))}</span>
<button class="track-action-btn queue-btn" title="Add to Queue">+</button>
</div>
`;
}
// ... (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 ? '<span class="hires-badge">HI-RES</span>' : '';
return `
<div class="album-card" data-id="${album.id}" data-year="${year || '0'}">
<div class="album-card-art-container">
<img class="album-card-art" src="${album.album_art || '/static/icon.svg'}" alt="${escapeHtml(album.name)}" loading="lazy">
${hiResBadge}
</div>
<div class="album-card-info">
<p class="album-card-title">${escapeHtml(album.name)}</p>
<p class="album-card-artist">${escapeHtml(album.artists)}</p>
<div class="album-card-meta">
<span>${trackCount}</span>
<span>${year}</span>
</div>
</div>
</div>
`;
}
function renderArtistCard(artist) {
const followers = artist.followers ? `${(artist.followers / 1000).toFixed(0)}K followers` : '';
return `
<div class="artist-item" data-id="${artist.id}">
<img class="artist-art" src="${artist.image || '/static/icon.svg'}" alt="Artist" loading="lazy">
<div class="artist-info">
<p class="artist-name">${escapeHtml(artist.name)}</p>
<p class="artist-genres">${artist.genres?.slice(0, 2).join(', ') || 'Artist'}</p>
<p class="artist-followers">${followers}</p>
</div>
</div>
`;
}
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 = `
<div style="text-align: center; margin-bottom: 20px;">
<h2 style="font-size: 1.5rem; margin-bottom: 4px;">${escapeHtml(setlist.artists)}</h2>
<p style="font-size: 1.1rem; color: var(--text-secondary); margin-bottom: 4px;">${escapeHtml(setlist.venue)}</p>
<p style="font-size: 1rem; color: var(--accent-color);">${setlist.date || setlist.release_date}</p>
<p style="font-size: 0.9rem; color: var(--text-tertiary); margin-top: 8px;">
${setlist.city}
</p>
</div>
`;
tracksEl.innerHTML = setlist.tracks.map((track, i) => `
<div class="setlist-track-item" style="display: flex; padding: 8px 0; border-bottom: 1px solid var(--border-color);">
<span style="color: var(--text-tertiary); width: 30px; text-align: right; margin-right: 12px; font-variant-numeric: tabular-nums;">${i + 1}</span>
<div style="flex: 1;">
<div style="display: flex; justify-content: space-between; align-items: baseline;">
<span style="font-weight: 500;">${escapeHtml(track.name)}</span>
${track.set_name ? `<span style="font-size: 0.75rem; color: var(--text-tertiary); background: var(--bg-secondary); padding: 2px 6px; border-radius: 4px;">${track.set_name}</span>` : ''}
</div>
${track.info ? `<p style="font-size: 0.8rem; color: var(--text-tertiary); margin: 2px 0 0;">${escapeHtml(track.info)}</p>` : ''}
${track.cover_info ? `<p style="font-size: 0.8rem; color: var(--text-tertiary); margin: 2px 0 0;">(Cover of ${escapeHtml(track.cover_info)})</p>` : ''}
</div>
</div>
`).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) => `
<div class="album-modal-track" data-index="${i}">
<span class="album-track-num">${i + 1}.</span>
<button class="album-track-play" title="Play" data-index="${i}"></button>
<div class="album-track-info">
<p class="album-track-name">${escapeHtml(track.name)}</p>
</div>
<button class="album-track-playlist" title="Add to Playlist" data-index="${i}"></button>
<span class="album-track-duration">${track.duration || '--:--'}</span>
<div class="album-track-actions">
<button title="Add to Queue" data-action="queue" data-index="${i}">+</button>
<button title="Download" data-action="download" data-index="${i}"></button>
</div>
</div>
`).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 = `
<img class="detail-art${isArtist ? ' artist-art' : ''}" src="${image}" alt="Cover">
<div class="detail-meta">
<p class="detail-name">${escapeHtml(item.name)}</p>
<p class="detail-artist">${escapeHtml(subtitle)}</p>
<p class="detail-stats">${stats}</p>
</div>
`;
// Render tracks with download button (and delete for user playlists)
detailTracks.innerHTML = tracks.map((t, i) => `
<div class="track-item" data-index="${i}" data-track-id="${t.id}">
<img class="track-album-art" src="${t.album_art || image}" alt="Art" loading="lazy">
<div class="track-info">
<p class="track-name">${escapeHtml(t.name)}</p>
<p class="track-artist">${escapeHtml(t.artists)}</p>
</div>
<div class="track-actions">
${renderDJBadgeForTrack(t)}
<span class="track-duration">${t.duration}</span>
${t.source === 'podcast' ? `
<button class="info-btn" title="Episode Details" onclick="event.stopPropagation(); showPodcastModal('${encodeURIComponent(JSON.stringify(t))}')"></button>
` : ''}
<button class="download-btn" title="Download" onclick="event.stopPropagation(); openDownloadModal('${encodeURIComponent(JSON.stringify(t))}')">
</button>
${isUserPlaylist ? `
<button class="delete-track-btn" title="Remove from playlist" onclick="event.stopPropagation(); deleteFromPlaylist('${item.id}', '${t.id}')">
</button>
` : ''}
</div>
</div>
`).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 = `
<img class="detail-art${isArtist ? ' artist-art' : ''}" src="${image}" alt="Cover">
<div class="detail-meta">
<p class="detail-name">${escapeHtml(item.name)}</p>
<p class="detail-artist">${escapeHtml(subtitle)}</p>
<p class="detail-stats">${stats}</p>
${!isSavedPlaylist && tracks.length > 0 ? `<button id="save-playlist-btn" class="save-playlist-btn">Save to Playlist</button>` : ''}
</div>
`;
// 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) => `
<div class="track-item" data-index="${i}">
<img class="track-album-art" src="${t.album_art || image}" alt="Art" loading="lazy">
<div class="track-info">
<p class="track-name">${escapeHtml(t.name)}</p>
<p class="track-artist">${escapeHtml(t.artists)}</p>
</div>
<span class="track-duration">${t.duration}</span>
</div>
`).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 = `
<div class="dj-badge-container" style="display: flex;">
<span class="dj-badge bpm-badge">${feat.bpm} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot}</span>
</div>
`;
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 = '<div class="dj-badge-placeholder"></div>';
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 = '<p style="text-align:center;color:var(--text-tertiary);padding:24px;">Queue is empty</p>';
return;
}
queueContainer.innerHTML = state.queue.filter(t => t).map((track, i) => `
<div class="queue-item" data-index="${i}">
<img class="track-album-art" src="${track.album_art || '/static/icon.svg'}" alt="Art" style="width:40px;height:40px;">
<div class="track-info">
<p class="track-name" style="font-size:0.875rem;">${escapeHtml(track.name || 'Unknown')}</p>
<p class="track-artist">${escapeHtml(track.artists || '')}</p>
</div>
<button class="queue-heart-btn" data-action="add-to-playlist" data-index="${i}" title="Add to Playlist">🩷</button>
<button class="queue-remove-btn" data-action="remove" data-index="${i}" title="Remove">×</button>
</div>
`).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 = `
<div class="dj-badge-container" style="display: flex;">
<span class="dj-badge bpm-badge">${feat.bpm} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot}</span>
</div>
`;
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 = `
<div class="empty-state">
<span class="empty-icon">🔍</span>
<p>Search for your favorite music</p>
<p class="hint">Or paste a Spotify link to an album or playlist</p>
</div>
`;
}
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 = `
<div class="dj-badge-container" style="display: flex; justify-content: center; gap: 8px; margin-top: 8px;">
<span class="dj-badge bpm-badge">${feat.bpm} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot}</span>
</div>
`;
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 '<div class="dj-badge-placeholder" data-id="' + track.id + '"></div>';
const camelotClass = feat.camelot ? `camelot-${feat.camelot}` : '';
return `
<div class="dj-badge-container" style="display: flex;">
<span class="dj-badge bpm-badge">${feat.bpm} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot}</span>
</div>
`;
}
// 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 = `
<div class="dj-badge-container" style="display: flex;">
<span class="dj-badge bpm-badge">${feat.bpm} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot}</span>
</div>
`;
}
});
// 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 += `
<div class="dj-track-item">
<div class="dj-track-number">${i + 1}</div>
<div class="dj-track-info">
<div class="dj-track-name">${escapeHtml(track.name)}</div>
<div class="dj-track-artist">${escapeHtml(track.artists)}</div>
</div>
<div class="dj-track-meta">
<span class="dj-badge bpm-badge">${feat.bpm || '?'} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot || '?'}</span>
</div>
</div>
`;
// 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 ? `<span class="dj-technique-badge">${escapeHtml(sug.technique)}</span>` : '';
const timing = sug.timing ? `<span class="dj-timing">${escapeHtml(sug.timing)}</span>` : '';
const tipText = sug.tip ? escapeHtml(sug.tip) : '';
html += `
<div class="dj-transition ${tipClass}">
<div class="dj-transition-header">
💡 ${technique} ${timing}
</div>
<div class="dj-transition-tip">${tipText}</div>
</div>
`;
}
});
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 = `
<span class="dj-badge bpm-badge">${feat.bpm} BPM</span>
<span class="dj-badge camelot-badge ${camelotClass}">${feat.camelot}</span>
<div class="energy-bar"><div class="energy-fill" style="width: ${feat.energy * 100}%"></div></div>
`;
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 = `
<span class="spinner-small"></span>
<span>${message}</span>
`;
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 = '<p style="color: var(--text-tertiary); text-align:center; padding:16px;">No playlists yet. Create one below!</p>';
} else {
playlistList.innerHTML = state.playlists.map(p => `
<div class="playlist-list-item" data-playlist-id="${p.id}">
${escapeHtml(p.name)} <span style="opacity:0.6">(${p.tracks.length})</span>
</div>
`).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 += `
<div class="results-header">
<h2>Spotify For You</h2>
<span class="results-count">${spotifyPlaylists.length} playlists</span>
</div>
<div class="results-grid horizontal-scroll" id="spotify-mfy-grid">
`;
for (const playlist of spotifyPlaylists) {
html += `
<div class="album-card spotify-mfy-card" data-id="${playlist.id}">
<div class="album-card-art-container">
<img class="album-card-art" src="${playlist.image || '/static/icon.png'}" alt="${escapeHtml(playlist.name)}" loading="lazy">
<span class="hires-badge" style="background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);">Spotify</span>
</div>
<div class="album-card-info">
<p class="album-card-title">${escapeHtml(playlist.name)}</p>
<p class="album-card-artist">${escapeHtml(playlist.owner || 'Spotify')}</p>
</div>
</div>
`;
}
html += '</div>';
}
}
} 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 = `
<div class="empty-state">
<div class="empty-icon"></div>
<p class="empty-text">Connect Spotify or ListenBrainz to see personalized recommendations.</p>
<p class="empty-text" style="font-size: 0.9em; opacity: 0.8; margin-top: 8px;">Set SPOTIFY_SP_DC or LISTENBRAINZ_TOKEN in your environment variables.</p>
</div>
`;
} 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 += `
<div class="stats-panel">
<div class="stats-item">
<span class="stats-value">${stats.listen_count.toLocaleString()}</span>
<span class="stats-label">Total Scrobbles</span>
</div>
${stats.top_artists.length > 0 ? `
<div class="stats-item top-artists">
<span class="stats-label">Top This Week</span>
<div class="stats-artists">
${stats.top_artists.slice(0, 3).map(a => `<span class="artist-tag">${escapeHtml(a.name)}</span>`).join('')}
</div>
</div>
` : ''}
</div>
`;
}
}
} catch (e) {
console.warn('Could not load LB stats:', e);
}
// Show playlists section
if (playlistsData.playlists && playlistsData.playlists.length > 0) {
html += `
<div class="results-header">
<h2>ListenBrainz Playlists</h2>
<span class="results-count">${playlistsData.count} playlists</span>
</div>
<div class="results-grid" id="lb-playlists-grid">
`;
for (const playlist of playlistsData.playlists) {
html += `
<div class="album-card lb-playlist-card" data-id="${playlist.id}">
<div class="album-card-art-container">
<img class="album-card-art" src="/static/icon.svg" alt="${escapeHtml(playlist.name)}" loading="lazy">
<span class="hires-badge" style="background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);">LB</span>
</div>
<div class="album-card-info">
<p class="album-card-title">${escapeHtml(playlist.name)}</p>
<p class="album-card-artist">${escapeHtml(playlist.artists)}</p>
<div class="album-card-meta">
<span>${playlist.total_tracks || '?'} tracks</span>
</div>
</div>
</div>
`;
}
html += '</div>';
} else {
html += `
<div class="results-header">
<h2>ListenBrainz Playlists</h2>
</div>
<div class="empty-state" style="margin-bottom: 24px;">
<div class="empty-icon">📋</div>
<p class="empty-text">No playlists found. Weekly Exploration playlists are generated on Mondays!</p>
</div>
`;
}
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 = `
<div class="winamp-player">
<div class="winamp-titlebar">
<div style="width:10px; height:10px; background:#fff; margin-right:4px; clip-path: polygon(50% 0, 0 100%, 100% 100%);"></div>
<span class="winamp-titlebar-text">FREEDIFY</span>
<span style="flex:1"></span>
<div style="background:#808080; width:8px; height:8px; border:1px solid #fff; cursor:pointer;" onclick="window.close()"></div>
</div>
<div class="winamp-main">
<div class="winamp-art">
<img id="wa-art" src="" />
</div>
<div class="winamp-content">
<div class="winamp-display">
<div id="wa-time" class="winamp-time">00:00</div>
<div id="wa-marquee" class="winamp-marquee"><span>Ready to Llama...</span></div>
<div class="winamp-info-line">
<span id="wa-format" style="color:#00e000; font-weight:bold;">MP3</span>
<span id="wa-state" style="margin-left:8px;">STOP</span>
</div>
</div>
<div class="winamp-controls">
<div class="winamp-btn" id="wa-prev" title="Prev"><svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg></div>
<div class="winamp-btn" id="wa-play" title="Play"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
<div class="winamp-btn" id="wa-pause" title="Pause"><svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg></div>
<div class="winamp-btn" id="wa-next" title="Next"><svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg></div>
<div style="font-size: 8px; color: #fff; margin-left: 4px; font-family: 'Courier New', monospace;">VOL</div>
<input type="range" class="winamp-slider" id="wa-vol" title="Volume Control" min="0" max="100" value="${state.volume * 100}">
</div>
</div>
</div>
</div>
`;
}
}
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 = '<div class="ai-loading">Generating playlist</div>';
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 = `
<div class="ai-results-header">
<span>🎵 ${data.playlist_name || 'Generated Playlist'}</span>
<span>${data.tracks.length} tracks</span>
</div>
`;
data.tracks.forEach((track, i) => {
html += `
<div class="ai-track-item" data-artist="${escapeHtml(track.artist)}" data-title="${escapeHtml(track.title)}">
<span style="color: var(--text-tertiary); width: 24px;">${i + 1}</span>
<div class="ai-track-info">
<div class="ai-track-title">${escapeHtml(track.title)}</div>
<div class="ai-track-artist">${escapeHtml(track.artist)}</div>
</div>
</div>
`;
});
html += '<button class="ai-add-all-btn" id="ai-add-all"> Add All to Queue</button>';
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 = '<p style="color: var(--text-secondary);">Could not generate playlist. Try a different description.</p>';
}
} catch (e) {
console.error('Playlist generation error:', e);
aiPlaylistResults.innerHTML = '<p style="color: var(--error);">Playlist generation failed. Please try again.</p>';
} 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 = `<strong>Released:</strong> ${data.release_date}`;
}
if (data.writers && data.writers.length > 0) {
aboutWriters.innerHTML = `<strong>Written by:</strong> ${data.writers.join(', ')}`;
}
if (data.producers && data.producers.length > 0) {
aboutProducers.innerHTML = `<strong>Produced by:</strong> ${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 => `
<div class="annotation-item">
<div class="annotation-fragment">"${ann.fragment}"</div>
<div class="annotation-text">${ann.text}</div>
</div>
`).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 `
<div class="concert-card">
${event.image
? `<img class="concert-card-image" src="${event.image}" alt="${event.artist}" onerror="this.outerHTML='<div class=\\'concert-card-image placeholder\\'>🎵</div>'">`
: '<div class="concert-card-image placeholder">🎵</div>'
}
<div class="concert-card-info">
<div class="concert-card-artist">
${event.artist || event.name}
<span class="concert-source-badge">${event.source}</span>
</div>
<div class="concert-card-venue">📍 ${event.venue}${location ? `, ${location}` : ''}</div>
<div class="concert-card-date">📅 ${date}${time ? `${time}` : ''}</div>
${priceRange ? `<div class="concert-card-price">💰 ${priceRange}</div>` : ''}
</div>
<div class="concert-card-actions">
${event.ticket_url
? `<a href="${event.ticket_url}" target="_blank" rel="noopener" class="concert-ticket-btn">🎫 Tickets</a>`
: ''
}
</div>
</div>
`;
}).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');