6537 lines
226 KiB
JavaScript
6537 lines
226 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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');
|