diff --git a/index.html b/index.html
index aa8a7a4..864d97e 100644
--- a/index.html
+++ b/index.html
@@ -229,25 +229,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
diff --git a/js/app.js b/js/app.js
index df2e31c..1ebb8cd 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1,351 +1,196 @@
-//app.js
import { LosslessAPI } from './api.js';
-import { apiSettings, themeManager, lastFMStorage } from './storage.js';
+import { apiSettings, themeManager, nowPlayingSettings } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import { LastFMScrobbler } from './lastfm.js';
-import {
- REPEAT_MODE, SVG_PLAY, SVG_PAUSE,
- SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore,
- buildTrackFilename, RATE_LIMIT_ERROR_MESSAGE, debounce,
- sanitizeForFilename,
- getTrackArtists,
- getTrackTitle
-} from './utils.js';
+import { LyricsManager, createLyricsPanel, showKaraokeView } from './lyrics.js';
+import { createRouter, updateTabTitle } from './router.js';
+import { initializeSettings } from './settings.js';
+import { initializePlayerEvents, initializeTrackInteractions } from './events.js';
+import { initializeUIInteractions } from './ui-interactions.js';
+import { downloadAlbumAsZip, downloadDiscography, downloadCurrentTrack } from './downloads.js';
+import { debounce, SVG_PLAY } from './utils.js';
-const downloadTasks = new Map();
-let downloadNotificationContainer = null;
-
-async function loadJSZip() {
- try {
- const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
- return module.default;
- } catch (error) {
- console.error('Failed to load JSZip:', error);
- throw new Error('Failed to load ZIP library');
+function initializeCasting(audioPlayer, castBtn) {
+ if (!castBtn) return;
+
+ // Check for Remote Playback API (Chrome)
+ if ('remote' in audioPlayer) {
+ audioPlayer.remote.watchAvailability((available) => {
+ if (available) {
+ castBtn.style.display = 'flex';
+ castBtn.classList.add('available');
+ }
+ }).catch(err => {
+ console.log('Remote playback not available:', err);
+ // Still show button on desktop
+ if (window.innerWidth > 768) {
+ castBtn.style.display = 'flex';
+ }
+ });
+
+ castBtn.addEventListener('click', () => {
+ audioPlayer.remote.prompt().catch(err => {
+ console.log('Cast prompt error:', err);
+ });
+ });
+
+ // Listen for connection state changes
+ audioPlayer.addEventListener('playing', () => {
+ if (audioPlayer.remote && audioPlayer.remote.state === 'connected') {
+ castBtn.classList.add('connected');
+ }
+ });
+
+ audioPlayer.addEventListener('pause', () => {
+ if (audioPlayer.remote && audioPlayer.remote.state === 'disconnected') {
+ castBtn.classList.remove('connected');
+ }
+ });
+ }
+ // Check for AirPlay (Safari)
+ else if (audioPlayer.webkitShowPlaybackTargetPicker) {
+ castBtn.style.display = 'flex';
+ castBtn.classList.add('available');
+
+ castBtn.addEventListener('click', () => {
+ audioPlayer.webkitShowPlaybackTargetPicker();
+ });
+
+ // Listen for AirPlay connection state
+ audioPlayer.addEventListener('webkitplaybacktargetavailabilitychanged', (e) => {
+ if (e.availability === 'available') {
+ castBtn.classList.add('available');
+ }
+ });
+
+ audioPlayer.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => {
+ if (audioPlayer.webkitCurrentPlaybackTargetIsWireless) {
+ castBtn.classList.add('connected');
+ } else {
+ castBtn.classList.remove('connected');
+ }
+ });
+ }
+ // Show on desktop anyway
+ else if (window.innerWidth > 768) {
+ castBtn.style.display = 'flex';
+ castBtn.addEventListener('click', () => {
+ alert('Casting is not supported in this browser. Try Chrome for Chromecast or Safari for AirPlay.');
+ });
}
}
-function createDownloadNotification() {
- if (!downloadNotificationContainer) {
- downloadNotificationContainer = document.createElement('div');
- downloadNotificationContainer.id = 'download-notifications';
- document.body.appendChild(downloadNotificationContainer);
- }
- return downloadNotificationContainer;
-}
-
-function addDownloadTask(trackId, track, filename, api) {
- const container = createDownloadNotification();
-
- const taskEl = document.createElement('div');
- taskEl.className = 'download-task';
- taskEl.dataset.trackId = trackId;
- const trackTitle = getTrackTitle(track);
- taskEl.innerHTML = `
-
-
-
-
${trackTitle}
-
${track.artist?.name || 'Unknown'}
-
-
Starting...
-
-
-
-
-
-
-
-
- `;
-
- container.appendChild(taskEl);
-
- const abortController = new AbortController();
- downloadTasks.set(trackId, { taskEl, abortController });
-
- taskEl.querySelector('.download-cancel').addEventListener('click', () => {
- abortController.abort();
- removeDownloadTask(trackId);
+function initializeKeyboardShortcuts(player, audioPlayer) {
+ document.addEventListener('keydown', (e) => {
+ // Don't trigger shortcuts when typing in inputs
+ if (e.target.matches('input, textarea')) return;
+
+ switch(e.key.toLowerCase()) {
+ case ' ':
+ e.preventDefault();
+ player.handlePlayPause();
+ break;
+ case 'arrowright':
+ if (e.shiftKey) {
+ player.playNext();
+ } else {
+ audioPlayer.currentTime = Math.min(
+ audioPlayer.duration,
+ audioPlayer.currentTime + 10
+ );
+ }
+ break;
+ case 'arrowleft':
+ if (e.shiftKey) {
+ player.playPrev();
+ } else {
+ audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
+ }
+ break;
+ case 'arrowup':
+ e.preventDefault();
+ audioPlayer.volume = Math.min(1, audioPlayer.volume + 0.1);
+ break;
+ case 'arrowdown':
+ e.preventDefault();
+ audioPlayer.volume = Math.max(0, audioPlayer.volume - 0.1);
+ break;
+ case 'm':
+ audioPlayer.muted = !audioPlayer.muted;
+ break;
+ case 's':
+ document.getElementById('shuffle-btn')?.click();
+ break;
+ case 'r':
+ document.getElementById('repeat-btn')?.click();
+ break;
+ case 'q':
+ document.getElementById('queue-btn')?.click();
+ break;
+ case '/':
+ e.preventDefault();
+ document.getElementById('search-input')?.focus();
+ break;
+ case 'escape':
+ document.getElementById('search-input')?.blur();
+ document.getElementById('queue-modal-overlay').style.display = 'none';
+ const lyricsPanel = document.getElementById('lyrics-panel');
+ if (lyricsPanel) {
+ lyricsPanel.classList.add('hidden');
+ }
+ const karaokeView = document.getElementById('karaoke-view');
+ if (karaokeView) {
+ karaokeView.remove();
+ }
+ break;
+ case 'l':
+ // Toggle lyrics
+ document.querySelector('.now-playing-bar .cover')?.click();
+ break;
+ }
});
-
- return { taskEl, abortController };
}
-function updateDownloadProgress(trackId, progress) {
- const task = downloadTasks.get(trackId);
- if (!task) return;
+function initializeMediaSessionHandlers(player) {
+ if (!('mediaSession' in navigator)) return;
- const { taskEl } = task;
- const progressFill = taskEl.querySelector('.download-progress-fill');
- const statusEl = taskEl.querySelector('.download-status');
-
- if (progress.stage === 'downloading') {
- const percent = progress.totalBytes
- ? Math.round((progress.receivedBytes / progress.totalBytes) * 100)
- : 0;
-
- progressFill.style.width = `${percent}%`;
-
- const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
- const totalMB = progress.totalBytes
- ? (progress.totalBytes / (1024 * 1024)).toFixed(1)
- : '?';
-
- statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
+ try {
+ navigator.mediaSession.setActionHandler('seekto', (details) => {
+ if (details.seekTime !== undefined && details.fastSeek !== undefined && details.fastSeek) {
+ player.audio.currentTime = details.seekTime;
+ player.updateMediaSessionPositionState();
+ }
+ });
+ } catch (error) {
+ console.log('seekto action not supported');
}
}
-function completeDownloadTask(trackId, success = true, message = null) {
- const task = downloadTasks.get(trackId);
- if (!task) return;
-
- const { taskEl } = task;
- const progressFill = taskEl.querySelector('.download-progress-fill');
- const statusEl = taskEl.querySelector('.download-status');
- const cancelBtn = taskEl.querySelector('.download-cancel');
-
- if (success) {
- progressFill.style.width = '100%';
- progressFill.style.background = '#10b981';
- statusEl.textContent = '✓ Downloaded';
- statusEl.style.color = '#10b981';
- cancelBtn.remove();
-
- setTimeout(() => removeDownloadTask(trackId), 3000);
- } else {
- progressFill.style.background = '#ef4444';
- statusEl.textContent = message || '✗ Download failed';
- statusEl.style.color = '#ef4444';
- cancelBtn.innerHTML = `
-
-
-
-
- `;
- cancelBtn.onclick = () => removeDownloadTask(trackId);
-
- setTimeout(() => removeDownloadTask(trackId), 5000);
- }
-}
-
-function removeDownloadTask(trackId) {
- const task = downloadTasks.get(trackId);
- if (!task) return;
-
- const { taskEl } = task;
- taskEl.style.animation = 'slideOut 0.3s ease';
+function showOfflineNotification() {
+ const notification = document.createElement('div');
+ notification.className = 'offline-notification';
+ notification.innerHTML = `
+
+
+
+
+
+
You are offline. Some features may not work.
+ `;
+ document.body.appendChild(notification);
setTimeout(() => {
- taskEl.remove();
- downloadTasks.delete(trackId);
-
- if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
- downloadNotificationContainer.remove();
- downloadNotificationContainer = null;
- }
- }, 300);
+ notification.style.animation = 'slideOut 0.3s ease forwards';
+ setTimeout(() => notification.remove(), 300);
+ }, 5000);
}
-async function downloadTrackBlob(track, quality, api) {
- const lookup = await api.getTrack(track.id, quality);
- let streamUrl;
-
- if (lookup.originalTrackUrl) {
- streamUrl = lookup.originalTrackUrl;
- } else {
- streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
- if (!streamUrl) {
- throw new Error('Could not resolve stream URL');
- }
- }
-
- const response = await fetch(streamUrl);
- if (!response.ok) {
- throw new Error(`Failed to fetch track: ${response.status}`);
- }
-
- const blob = await response.blob();
- return blob;
-}
-
-async function downloadAlbumAsZip(album, tracks, api, quality) {
- const JSZip = await loadJSZip();
- const zip = new JSZip();
-
- const artistName = sanitizeForFilename(album.artist?.name || 'Unknown Artist');
- const albumTitle = sanitizeForFilename(album.title || 'Unknown Album');
- const folderName = `${albumTitle} - ${artistName} - monochrome.tf`;
-
- const notification = createBulkDownloadNotification('album', album.title, tracks.length);
-
- try {
- for (let i = 0; i < tracks.length; i++) {
- const track = tracks[i];
- const filename = buildTrackFilename(track, quality);
- const trackTitle = getTrackTitle(track);
-
- updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
-
- const blob = await downloadTrackBlob(track, quality, api);
- zip.file(`${folderName}/${filename}`, blob);
- }
-
- updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...');
-
- const zipBlob = await zip.generateAsync({
- type: 'blob',
- compression: 'DEFLATE',
- compressionOptions: { level: 6 }
- });
-
- const url = URL.createObjectURL(zipBlob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `${folderName}.zip`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- completeBulkDownload(notification, true);
- } catch (error) {
- completeBulkDownload(notification, false, error.message);
- throw error;
- }
-}
-
-async function downloadDiscography(artist, api, quality) {
- const JSZip = await loadJSZip();
- const zip = new JSZip();
-
- const artistName = sanitizeForFilename(artist.name || 'Unknown Artist');
- const rootFolder = `${artistName} discography - monochrome.tf`;
-
- const totalAlbums = artist.albums.length;
- const notification = createBulkDownloadNotification('discography', artist.name, totalAlbums);
-
- try {
- for (let albumIndex = 0; albumIndex < artist.albums.length; albumIndex++) {
- const album = artist.albums[albumIndex];
-
- updateBulkDownloadProgress(notification, albumIndex, totalAlbums, album.title);
-
- try {
- const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
- const albumTitle = sanitizeForFilename(fullAlbum.title || 'Unknown Album');
- const albumFolder = `${rootFolder}/${albumTitle}`;
-
- for (const track of tracks) {
- const filename = buildTrackFilename(track, quality);
- const blob = await downloadTrackBlob(track, quality, api);
- zip.file(`${albumFolder}/${filename}`, blob);
- }
- } catch (error) {
- console.error(`Failed to download album ${album.title}:`, error);
- }
- }
-
- updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...');
-
- const zipBlob = await zip.generateAsync({
- type: 'blob',
- compression: 'DEFLATE',
- compressionOptions: { level: 6 }
- });
-
- const url = URL.createObjectURL(zipBlob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `${rootFolder}.zip`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- completeBulkDownload(notification, true);
- } catch (error) {
- completeBulkDownload(notification, false, error.message);
- throw error;
- }
-}
-
-function createBulkDownloadNotification(type, name, totalItems) {
- const container = createDownloadNotification();
-
- const notifEl = document.createElement('div');
- notifEl.className = 'download-task bulk-download';
-
- notifEl.innerHTML = `
-
-
-
- Downloading ${type === 'album' ? 'Album' : 'Discography'}
-
-
${name}
-
-
Starting...
-
-
- `;
-
- container.appendChild(notifEl);
- return notifEl;
-}
-
-function updateBulkDownloadProgress(notifEl, current, total, currentItem) {
- const progressFill = notifEl.querySelector('.download-progress-fill');
- const statusEl = notifEl.querySelector('.download-status');
-
- const percent = total > 0 ? Math.round((current / total) * 100) : 0;
- progressFill.style.width = `${percent}%`;
- statusEl.textContent = `${current}/${total} - ${currentItem}`;
-}
-
-function completeBulkDownload(notifEl, success = true, message = null) {
- const progressFill = notifEl.querySelector('.download-progress-fill');
- const statusEl = notifEl.querySelector('.download-status');
-
- if (success) {
- progressFill.style.width = '100%';
- progressFill.style.background = '#10b981';
- statusEl.textContent = '✓ Download complete';
- statusEl.style.color = '#10b981';
-
- setTimeout(() => {
- notifEl.style.animation = 'slideOut 0.3s ease';
- setTimeout(() => notifEl.remove(), 300);
- }, 3000);
- } else {
- progressFill.style.background = '#ef4444';
- statusEl.textContent = message || '✗ Download failed';
- statusEl.style.color = '#ef4444';
-
- setTimeout(() => {
- notifEl.style.animation = 'slideOut 0.3s ease';
- setTimeout(() => notifEl.remove(), 300);
- }, 5000);
- }
-}
-
-async function loadHomeFeed(api) {
- try {
- const response = await api.fetchWithRetry('/home/');
- const data = await response.json();
-
- if (!Array.isArray(data) || data.length === 0) return null;
-
- const homeData = data[0];
- return homeData;
- } catch (error) {
- console.error('Failed to load home feed:', error);
- return null;
+function hideOfflineNotification() {
+ const notification = document.querySelector('.offline-notification');
+ if (notification) {
+ notification.style.animation = 'slideOut 0.3s ease forwards';
+ setTimeout(() => notification.remove(), 300);
}
}
@@ -358,319 +203,92 @@ document.addEventListener('DOMContentLoaded', async () => {
const player = new Player(audioPlayer, api, currentQuality);
const scrobbler = new LastFMScrobbler();
-
- const savedCrossfade = localStorage.getItem('crossfade-enabled') === 'true';
- const savedCrossfadeDuration = parseInt(localStorage.getItem('crossfade-duration') || '5');
- player.setCrossfade(savedCrossfade, savedCrossfadeDuration);
+ const lyricsManager = new LyricsManager(api);
+ const lyricsPanel = createLyricsPanel();
const currentTheme = themeManager.getTheme();
themeManager.setTheme(currentTheme);
- const mainContent = document.querySelector('.main-content');
- const playPauseBtn = document.querySelector('.play-pause-btn');
- const nextBtn = document.getElementById('next-btn');
- const prevBtn = document.getElementById('prev-btn');
- const shuffleBtn = document.getElementById('shuffle-btn');
- const repeatBtn = document.getElementById('repeat-btn');
- const progressBar = document.getElementById('progress-bar');
- const progressFill = document.getElementById('progress-fill');
- const currentTimeEl = document.getElementById('current-time');
- const totalDurationEl = document.getElementById('total-duration');
- const volumeBar = document.getElementById('volume-bar');
- const volumeFill = document.getElementById('volume-fill');
- const volumeBtn = document.getElementById('volume-btn');
- const contextMenu = document.getElementById('context-menu');
- const queueBtn = document.getElementById('queue-btn');
- const queueModalOverlay = document.getElementById('queue-modal-overlay');
- const closeQueueBtn = document.getElementById('close-queue-btn');
- const queueList = document.getElementById('queue-list');
- const searchForm = document.getElementById('search-form');
- const searchInput = document.getElementById('search-input');
- const sidebar = document.querySelector('.sidebar');
- const sidebarOverlay = document.getElementById('sidebar-overlay');
- const hamburgerBtn = document.getElementById('hamburger-btn');
-
- let contextTrack = null;
- let draggedQueueIndex = null;
-
- const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
- const lastfmStatus = document.getElementById('lastfm-status');
- const lastfmToggle = document.getElementById('lastfm-toggle');
- const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
-
- window.loadHomeFeed = loadHomeFeed;
-
- function positionContextMenu(menu, x, y, preferLeft = false) {
- menu.style.display = 'block';
- menu.style.visibility = 'hidden';
-
- const menuRect = menu.getBoundingClientRect();
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- let finalX = x;
- let finalY = y;
-
- if (preferLeft || (x + menuRect.width > viewportWidth)) {
- finalX = x - menuRect.width;
- if (finalX < 0) {
- finalX = Math.min(x, viewportWidth - menuRect.width - 10);
- }
- }
-
- if (finalX < 10) {
- finalX = 10;
- }
-
- if (finalX + menuRect.width > viewportWidth - 10) {
- finalX = viewportWidth - menuRect.width - 10;
- }
-
- if (y + menuRect.height > viewportHeight) {
- finalY = Math.max(10, y - menuRect.height);
- }
-
- if (finalY + menuRect.height > viewportHeight - 10) {
- finalY = viewportHeight - menuRect.height - 10;
- }
-
- if (finalY < 10) {
- finalY = 10;
- }
-
- menu.style.left = `${finalX}px`;
- menu.style.top = `${finalY}px`;
- menu.style.visibility = 'visible';
- }
-
- function updateLastFMUI() {
- if (scrobbler.isAuthenticated()) {
- lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
- lastfmConnectBtn.textContent = 'Disconnect';
- lastfmConnectBtn.classList.add('danger');
- lastfmToggleSetting.style.display = 'flex';
- lastfmToggle.checked = lastFMStorage.isEnabled();
- } else {
- lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
- lastfmConnectBtn.textContent = 'Connect Last.fm';
- lastfmConnectBtn.classList.remove('danger');
- lastfmToggleSetting.style.display = 'none';
- }
- }
-
- updateLastFMUI();
-
- lastfmConnectBtn?.addEventListener('click', async () => {
- if (scrobbler.isAuthenticated()) {
- if (confirm('Disconnect from Last.fm?')) {
- scrobbler.disconnect();
- updateLastFMUI();
- }
+ // Initialize all modules
+ initializeSettings(scrobbler, player, api, ui);
+ initializePlayerEvents(player, audioPlayer, scrobbler);
+ initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'));
+ initializeUIInteractions(player, api);
+ initializeKeyboardShortcuts(player, audioPlayer);
+ initializeMediaSessionHandlers(player);
+
+ // Initialize casting
+ const castBtn = document.getElementById('cast-btn');
+ initializeCasting(audioPlayer, castBtn);
+
+ // Album art click handler for lyrics
+ document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => {
+ if (!player.currentTrack) {
+ alert('No track is currently playing');
return;
}
-
- const authWindow = window.open('', '_blank');
-
- lastfmConnectBtn.disabled = true;
- lastfmConnectBtn.textContent = 'Opening Last.fm...';
-
- try {
- const { token, url } = await scrobbler.getAuthUrl();
-
- if (authWindow) {
- authWindow.location.href = url;
+
+ const mode = nowPlayingSettings.getMode();
+
+ if (mode === 'karaoke') {
+ // Show karaoke view
+ lyricsPanel.classList.add('hidden');
+ const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id);
+ if (lyricsData) {
+ showKaraokeView(player.currentTrack, lyricsData, audioPlayer);
} else {
- alert('Popup blocked! Please allow popups.');
- lastfmConnectBtn.textContent = 'Connect Last.fm';
- lastfmConnectBtn.disabled = false;
- return;
+ alert('No lyrics available for this track');
}
-
- lastfmConnectBtn.textContent = 'Waiting for authorization...';
-
- let attempts = 0;
- const maxAttempts = 30;
-
- const checkAuth = setInterval(async () => {
- attempts++;
-
- if (attempts > maxAttempts) {
- clearInterval(checkAuth);
- lastfmConnectBtn.textContent = 'Connect Last.fm';
- lastfmConnectBtn.disabled = false;
- if (authWindow && !authWindow.closed) authWindow.close();
- alert('Authorization timed out. Please try again.');
- return;
- }
-
- try {
- const result = await scrobbler.completeAuthentication(token);
-
- if (result.success) {
- clearInterval(checkAuth);
- if (authWindow && !authWindow.closed) authWindow.close();
- updateLastFMUI();
- lastfmConnectBtn.disabled = false;
- lastFMStorage.setEnabled(true);
- lastfmToggle.checked = true;
- alert(`Successfully connected to Last.fm as ${result.username}!`);
+ } else if (mode === 'lyrics') {
+ // Toggle lyrics panel
+ const isHidden = lyricsPanel.classList.contains('hidden');
+ lyricsPanel.classList.toggle('hidden');
+
+ if (isHidden) {
+ const content = lyricsPanel.querySelector('.lyrics-content');
+ content.innerHTML = '
Loading lyrics...
';
+
+ const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id);
+
+ if (lyricsData) {
+ lyricsManager.currentLyrics = lyricsData;
+
+ if (lyricsData.lyrics) {
+ const lines = lyricsData.lyrics.split('\n');
+ content.innerHTML = lines.map(line =>
+ `
${line || ' '}
`
+ ).join('');
+ } else {
+ content.innerHTML = '
No lyrics available
';
}
- } catch (e) {
+ } else {
+ content.innerHTML = '
Failed to load lyrics
';
}
- }, 2000);
-
- } catch (error) {
- console.error('Last.fm connection failed:', error);
- alert('Failed to connect to Last.fm: ' + error.message);
- lastfmConnectBtn.textContent = 'Connect Last.fm';
- lastfmConnectBtn.disabled = false;
- if (authWindow && !authWindow.closed) authWindow.close();
- }
- });
-
- lastfmToggle?.addEventListener('change', (e) => {
- lastFMStorage.setEnabled(e.target.checked);
- });
-
- const themePicker = document.getElementById('theme-picker');
- themePicker.querySelectorAll('.theme-option').forEach(option => {
- if (option.dataset.theme === currentTheme) {
- option.classList.add('active');
- }
-
- option.addEventListener('click', () => {
- const theme = option.dataset.theme;
-
- themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active'));
- option.classList.add('active');
-
- if (theme === 'custom') {
- document.getElementById('custom-theme-editor').classList.add('show');
- renderCustomThemeEditor();
- } else {
- document.getElementById('custom-theme-editor').classList.remove('show');
- themeManager.setTheme(theme);
- }
- });
- });
-
- document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
- const btn = document.getElementById('refresh-speed-test-btn');
- const originalText = btn.textContent;
- btn.textContent = 'Testing...';
- btn.disabled = true;
-
- try {
- await apiSettings.refreshSpeedTests();
- ui.renderApiSettings();
- btn.textContent = 'Done!';
- setTimeout(() => {
- btn.textContent = originalText;
- btn.disabled = false;
- }, 1500);
- } catch (error) {
- console.error('Failed to refresh speed tests:', error);
- btn.textContent = 'Error';
- setTimeout(() => {
- btn.textContent = originalText;
- btn.disabled = false;
- }, 1500);
- }
- });
-
- function renderCustomThemeEditor() {
- const grid = document.getElementById('theme-color-grid');
- const customTheme = themeManager.getCustomTheme() || {
- background: '#000000',
- foreground: '#fafafa',
- primary: '#ffffff',
- secondary: '#27272a',
- muted: '#27272a',
- border: '#27272a',
- highlight: '#ffffff'
- };
-
- grid.innerHTML = Object.entries(customTheme).map(([key, value]) => `
-
- ${key}
-
-
- `).join('');
- }
-
- document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
- const colors = {};
- document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => {
- colors[input.dataset.color] = input.value;
- });
- themeManager.setCustomTheme(colors);
- });
-
- document.getElementById('reset-custom-theme')?.addEventListener('click', () => {
- renderCustomThemeEditor();
- });
-
- const crossfadeToggle = document.getElementById('crossfade-toggle');
- const crossfadeDurationSetting = document.getElementById('crossfade-duration-setting');
- const crossfadeDurationInput = document.getElementById('crossfade-duration');
-
- crossfadeToggle.checked = savedCrossfade;
- crossfadeDurationSetting.style.display = savedCrossfade ? 'flex' : 'none';
- crossfadeDurationInput.value = savedCrossfadeDuration;
-
- crossfadeToggle.addEventListener('change', (e) => {
- const enabled = e.target.checked;
- localStorage.setItem('crossfade-enabled', enabled);
- crossfadeDurationSetting.style.display = enabled ? 'flex' : 'none';
- player.setCrossfade(enabled, parseInt(crossfadeDurationInput.value));
- });
-
- crossfadeDurationInput.addEventListener('change', (e) => {
- const duration = parseInt(e.target.value);
- localStorage.setItem('crossfade-duration', duration);
- player.setCrossfade(crossfadeToggle.checked, duration);
- });
-
- const qualitySetting = document.getElementById('quality-setting');
- if (qualitySetting) {
- const savedQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
- qualitySetting.value = savedQuality;
- player.setQuality(savedQuality);
-
- qualitySetting.addEventListener('change', (e) => {
- const newQuality = e.target.value;
- player.setQuality(newQuality);
- localStorage.setItem('playback-quality', newQuality);
- });
- }
-
- const normalizeToggle = document.querySelectorAll('.setting-item').forEach(item => {
- const label = item.querySelector('.label');
- if (label && label.textContent.includes('Normalize Volume')) {
- const toggle = item.querySelector('input[type="checkbox"]');
- if (toggle) {
- toggle.checked = localStorage.getItem('normalize-volume') === 'true';
- toggle.addEventListener('change', (e) => {
- localStorage.setItem('normalize-volume', e.target.checked ? 'true' : 'false');
- });
}
}
+ // If mode is 'cover', do nothing (default behavior)
});
-
- document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
- const track = player.currentTrack;
- if (track?.album?.id) {
- window.location.hash = `#album/${track.album.id}`;
+
+ // Close lyrics panel
+ document.getElementById('close-lyrics-btn')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ lyricsPanel.classList.add('hidden');
+ });
+
+ // Download LRC button
+ document.getElementById('download-lrc-btn')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (lyricsManager.currentLyrics && player.currentTrack) {
+ lyricsManager.downloadLRC(lyricsManager.currentLyrics, player.currentTrack);
}
});
-
- document.querySelector('.now-playing-bar .artist').addEventListener('click', () => {
- const track = player.currentTrack;
- if (track?.artist?.id) {
- window.location.hash = `#artist/${track.artist.id}`;
- }
+
+ // Download current track button
+ document.getElementById('download-current-btn')?.addEventListener('click', () => {
+ downloadCurrentTrack(player.currentTrack, player.quality, api, lyricsManager);
});
-
+
+ // Album/Discography downloads
document.addEventListener('click', async (e) => {
if (e.target.closest('#play-album-btn')) {
const btn = e.target.closest('#play-album-btn');
@@ -683,7 +301,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const { tracks } = await api.getAlbum(albumId);
if (tracks.length > 0) {
player.setQueue(tracks, 0);
- shuffleBtn.classList.remove('active');
+ document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue();
}
} catch (error) {
@@ -705,7 +323,7 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
const { album, tracks } = await api.getAlbum(albumId);
- await downloadAlbumAsZip(album, tracks, api, player.quality);
+ await downloadAlbumAsZip(album, tracks, api, player.quality, lyricsManager);
} catch (error) {
console.error('Album download failed:', error);
alert('Failed to download album: ' + error.message);
@@ -728,7 +346,7 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
const artist = await api.getArtist(artistId);
- await downloadDiscography(artist, api, player.quality);
+ await downloadDiscography(artist, api, player.quality, lyricsManager);
} catch (error) {
console.error('Discography download failed:', error);
alert('Failed to download discography: ' + error.message);
@@ -738,260 +356,24 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
});
-
- document.querySelectorAll('.search-tab').forEach(tab => {
- tab.addEventListener('click', () => {
- document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
- document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
-
- tab.classList.add('active');
- document.getElementById(`search-tab-${tab.dataset.tab}`).classList.add('active');
- });
- });
-
- const router = () => {
- const path = window.location.hash.substring(1) || "home";
- const [page, param] = path.split('/');
-
- switch (page) {
- case 'search':
- ui.renderSearchPage(decodeURIComponent(param));
- break;
- case 'album':
- ui.renderAlbumPage(param);
- break;
- case 'artist':
- ui.renderArtistPage(param);
- break;
- case 'home':
- ui.renderHomePage();
- break;
- default:
- ui.showPage(page);
- break;
- }
- };
-
- const renderQueue = () => {
- const currentQueue = player.getCurrentQueue();
-
- if (currentQueue.length === 0) {
- queueList.innerHTML = '
Queue is empty.
';
- return;
- }
-
- const html = currentQueue.map((track, index) => {
- const isPlaying = index === player.currentQueueIndex;
- const trackTitle = getTrackTitle(track);
- const trackArtists = getTrackArtists(track, {
- fallback: "Unknown"
- });
-
- return `
-
-
-
-
-
-
-
-
-
-
-
${trackTitle}
-
${trackArtists}
-
-
-
${formatTime(track.duration)}
-
-
- `;
- }).join('');
-
- queueList.innerHTML = html;
-
- queueList.querySelectorAll('.queue-track-item').forEach((item) => {
- const index = parseInt(item.dataset.queueIndex);
-
- item.addEventListener('click', (e) => {
- if (e.target.closest('.track-menu-btn')) return;
- player.playAtIndex(index);
- renderQueue();
- });
-
- item.addEventListener('dragstart', (e) => {
- draggedQueueIndex = index;
- item.style.opacity = '0.5';
- });
-
- item.addEventListener('dragend', () => {
- item.style.opacity = '1';
- });
-
- item.addEventListener('dragover', (e) => {
- e.preventDefault();
- });
-
- item.addEventListener('drop', (e) => {
- e.preventDefault();
- if (draggedQueueIndex !== null && draggedQueueIndex !== index) {
- player.moveInQueue(draggedQueueIndex, index);
- renderQueue();
- }
- });
- });
-
- queueList.querySelectorAll('.track-menu-btn').forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- const index = parseInt(btn.dataset.trackIndex);
- showQueueTrackMenu(e, index);
- });
- });
- };
-
- function showQueueTrackMenu(e, trackIndex) {
- const menu = document.getElementById('queue-track-menu');
- menu.style.top = `${e.pageY}px`;
- menu.style.left = `${e.pageX}px`;
- menu.classList.add('show');
- menu.dataset.trackIndex = trackIndex;
- positionContextMenu(menu, e.pageX, e.pageY, true);
- document.addEventListener('click', hideQueueTrackMenu);
- }
-
- function hideQueueTrackMenu() {
- const menu = document.getElementById('queue-track-menu');
- menu.classList.remove('show');
- document.removeEventListener('click', hideQueueTrackMenu);
- }
-
- document.getElementById('queue-track-menu').addEventListener('click', (e) => {
- e.stopPropagation();
- const action = e.target.dataset.action;
- const menu = document.getElementById('queue-track-menu');
- const trackIndex = parseInt(menu.dataset.trackIndex);
-
- if (action === 'remove') {
- player.removeFromQueue(trackIndex);
- renderQueue();
- }
-
- hideQueueTrackMenu();
- });
-
- mainContent.addEventListener('click', e => {
- const menuBtn = e.target.closest('.track-menu-btn');
- if (menuBtn) {
- e.stopPropagation();
- const trackItem = menuBtn.closest('.track-item');
- if (trackItem && !trackItem.dataset.queueIndex) {
- contextTrack = trackDataStore.get(trackItem);
- if (contextTrack) {
- const rect = menuBtn.getBoundingClientRect();
- contextMenu.style.top = `${rect.bottom + 5}px`;
- contextMenu.style.left = `${rect.left}px`;
- contextMenu.style.display = 'block';
- }
- }
- return;
- }
-
- const trackItem = e.target.closest('.track-item');
- if (trackItem && !trackItem.dataset.queueIndex) {
- const parentList = trackItem.closest('.track-list');
- const allTrackElements = Array.from(parentList.querySelectorAll('.track-item'));
- const trackList = allTrackElements.map(el => trackDataStore.get(el)).filter(Boolean);
-
- if (trackList.length > 0) {
- const clickedTrackId = trackItem.dataset.trackId;
- const startIndex = trackList.findIndex(t => t.id == clickedTrackId);
-
- player.setQueue(trackList, startIndex);
- shuffleBtn.classList.remove('active');
- player.playTrackFromQueue();
- }
- }
- });
-
- mainContent.addEventListener('contextmenu', e => {
- const trackItem = e.target.closest('.track-item');
- if (trackItem && !trackItem.dataset.queueIndex) {
- e.preventDefault();
- contextTrack = trackDataStore.get(trackItem);
-
- if (contextTrack) {
- contextMenu.style.top = `${e.pageY}px`;
- contextMenu.style.left = `${e.pageX}px`;
- contextMenu.style.display = 'block';
- }
- }
- });
-
- document.addEventListener('click', () => {
- contextMenu.style.display = 'none';
- });
-
- contextMenu.addEventListener('click', async e => {
- e.stopPropagation();
- const action = e.target.dataset.action;
-
- if (action === 'add-to-queue' && contextTrack) {
- player.addToQueue(contextTrack);
- renderQueue();
- } else if (action === 'download' && contextTrack) {
- const quality = player.quality;
- const filename = buildTrackFilename(contextTrack, quality);
-
- try {
- const { taskEl, abortController } = addDownloadTask(
- contextTrack.id,
- contextTrack,
- filename,
- api
- );
-
- await api.downloadTrack(contextTrack.id, quality, filename, {
- signal: abortController.signal,
- onProgress: (progress) => {
- updateDownloadProgress(contextTrack.id, progress);
- }
- });
-
- completeDownloadTask(contextTrack.id, true);
- } catch (error) {
- if (error.name !== 'AbortError') {
- const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
- ? error.message
- : 'Download failed. Please try again.';
- completeDownloadTask(contextTrack.id, false, errorMsg);
- }
- }
- }
-
- contextMenu.style.display = 'none';
- });
-
+
+ // Search
+ const searchForm = document.getElementById('search-form');
+ const searchInput = document.getElementById('search-input');
+
const performSearch = debounce((query) => {
if (query) {
window.location.hash = `#search/${encodeURIComponent(query)}`;
}
}, 300);
-
+
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query.length > 2) {
performSearch(query);
}
});
-
+
searchForm.addEventListener('submit', e => {
e.preventDefault();
const query = searchInput.value.trim();
@@ -999,264 +381,185 @@ document.addEventListener('DOMContentLoaded', async () => {
window.location.hash = `#search/${encodeURIComponent(query)}`;
}
});
-
- audioPlayer.addEventListener('play', () => {
- if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
- scrobbler.updateNowPlaying(player.currentTrack);
- }
- playPauseBtn.innerHTML = SVG_PAUSE;
- player.updateMediaSessionPlaybackState();
- });
-
- audioPlayer.addEventListener('pause', () => {
- playPauseBtn.innerHTML = SVG_PLAY;
- player.updateMediaSessionPlaybackState();
- });
-
- audioPlayer.addEventListener('ended', () => {
- player.playNext();
- });
-
- audioPlayer.addEventListener('timeupdate', () => {
- const { currentTime, duration } = audioPlayer;
- if (duration) {
- progressFill.style.width = `${(currentTime / duration) * 100}%`;
- currentTimeEl.textContent = formatTime(currentTime);
- player.updateMediaSessionPositionState();
- }
- });
-
- audioPlayer.addEventListener('loadedmetadata', () => {
- totalDurationEl.textContent = formatTime(audioPlayer.duration);
- player.updateMediaSessionPositionState();
- });
-
- audioPlayer.addEventListener('error', (e) => {
- console.error('Audio playback error:', e);
- document.querySelector('.now-playing-bar .artist').textContent = 'Playback error. Try another track.';
- playPauseBtn.innerHTML = SVG_PLAY;
- });
-
-let isSeeking = false;
-let wasPlaying = false;
-let isAdjustingVolume = false;
-
-const seek = (bar, fill, event, setter) => {
- const rect = bar.getBoundingClientRect();
- const position = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
- setter(position);
-};
-
-progressBar.addEventListener('mousedown', (e) => {
- isSeeking = true;
- wasPlaying = !audioPlayer.paused;
- if (wasPlaying) audioPlayer.pause();
- seek(progressBar, progressFill, e, position => {
- if (!isNaN(audioPlayer.duration)) {
- audioPlayer.currentTime = position * audioPlayer.duration;
- progressFill.style.width = `${position * 100}%`;
- }
+ // Network status monitoring
+ window.addEventListener('online', () => {
+ hideOfflineNotification();
+ console.log('Back online');
});
-});
-
-document.addEventListener('mousemove', (e) => {
- if (isSeeking) {
- seek(progressBar, progressFill, e, position => {
- if (!isNaN(audioPlayer.duration)) {
- audioPlayer.currentTime = position * audioPlayer.duration;
- progressFill.style.width = `${position * 100}%`;
- }
- });
- }
- if (isAdjustingVolume) {
- seek(volumeBar, volumeFill, e, position => {
- audioPlayer.volume = position;
- volumeFill.style.width = `${position * 100}%`;
- volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
- localStorage.setItem('volume', position);
- });
- }
-});
-
-document.addEventListener('mouseup', (e) => {
- if (isSeeking) {
- seek(progressBar, progressFill, e, position => {
- if (!isNaN(audioPlayer.duration)) {
- audioPlayer.currentTime = position * audioPlayer.duration;
- player.updateMediaSessionPositionState();
- if (wasPlaying) audioPlayer.play();
- }
- });
- isSeeking = false;
- }
+ window.addEventListener('offline', () => {
+ showOfflineNotification();
+ console.log('Gone offline');
+ });
- if (isAdjustingVolume) {
- isAdjustingVolume = false;
- }
-});
-
-progressBar.addEventListener('click', e => {
- if (!isSeeking) {
- seek(progressBar, progressFill, e, position => {
- if (!isNaN(audioPlayer.duration)) {
- audioPlayer.currentTime = position * audioPlayer.duration;
- player.updateMediaSessionPositionState();
- }
- });
- }
-});
-
-volumeBar.addEventListener('mousedown', (e) => {
- isAdjustingVolume = true;
+ // Initialize UI
+ document.querySelector('.play-pause-btn').innerHTML = SVG_PLAY;
- seek(volumeBar, volumeFill, e, position => {
- audioPlayer.volume = position;
- volumeFill.style.width = `${position * 100}%`;
- volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
- localStorage.setItem('volume', position);
- });
-});
-
-volumeBar.addEventListener('click', e => {
- if (!isAdjustingVolume) {
- seek(volumeBar, volumeFill, e, position => {
- audioPlayer.volume = position;
- volumeFill.style.width = `${position * 100}%`;
- volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
- localStorage.setItem('volume', position);
- });
- }
-});
-
- const updateVolumeUI = () => {
- const { volume, muted } = audioPlayer;
- volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
-
- const effectiveVolume = muted ? 0 : volume * 100;
-
- volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
- };
-
- volumeBtn.addEventListener('click', () => {
- audioPlayer.muted = !audioPlayer.muted;
- });
-
- audioPlayer.addEventListener('volumechange', updateVolumeUI);
-
- playPauseBtn.addEventListener('click', () => player.handlePlayPause());
- nextBtn.addEventListener('click', () => player.playNext());
- prevBtn.addEventListener('click', () => player.playPrev());
-
- shuffleBtn.addEventListener('click', () => {
- player.toggleShuffle();
- shuffleBtn.classList.toggle('active', player.shuffleActive);
- renderQueue();
- });
-
- repeatBtn.addEventListener('click', () => {
- const mode = player.toggleRepeat();
- repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
- repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
- repeatBtn.title = mode === REPEAT_MODE.OFF
- ? 'Repeat'
- : (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
- });
-
- queueBtn.addEventListener('click', () => {
- renderQueue();
- queueModalOverlay.style.display = 'flex';
- });
-
- closeQueueBtn.addEventListener('click', () => {
- queueModalOverlay.style.display = 'none';
- });
-
- queueModalOverlay.addEventListener('click', e => {
- if (e.target === queueModalOverlay) {
- queueModalOverlay.style.display = 'none';
- }
- });
-
- hamburgerBtn.addEventListener('click', () => {
- sidebar.classList.add('is-open');
- sidebarOverlay.classList.add('is-visible');
- });
-
- const closeSidebar = () => {
- sidebar.classList.remove('is-open');
- sidebarOverlay.classList.remove('is-visible');
- };
-
- sidebarOverlay.addEventListener('click', closeSidebar);
-
- sidebar.addEventListener('click', e => {
- if (e.target.closest('a')) {
- closeSidebar();
- }
- });
-
- document.getElementById('api-instance-list').addEventListener('click', async e => {
- const button = e.target.closest('button');
- if (!button) return;
-
- const li = button.closest('li');
- const index = parseInt(li.dataset.index, 10);
- const instances = await apiSettings.getInstances();
-
- if (button.classList.contains('move-up') && index > 0) {
- [instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
- } else if (button.classList.contains('move-down') && index < instances.length - 1) {
- [instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
- }
-
- apiSettings.saveInstances(instances);
- ui.renderApiSettings();
- });
-
- document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
- const btn = document.getElementById('clear-cache-btn');
- const originalText = btn.textContent;
- btn.textContent = 'Clearing...';
- btn.disabled = true;
-
- try {
- await api.clearCache();
- btn.textContent = 'Cleared!';
- setTimeout(() => {
- btn.textContent = originalText;
- btn.disabled = false;
- if (window.location.hash.includes('settings')) {
- ui.renderApiSettings();
- }
- }, 1500);
- } catch (error) {
- console.error('Failed to clear cache:', error);
- btn.textContent = 'Error';
- setTimeout(() => {
- btn.textContent = originalText;
- btn.disabled = false;
- }, 1500);
- }
- });
-
- playPauseBtn.innerHTML = SVG_PLAY;
- updateVolumeUI();
+ const router = createRouter(ui);
router();
window.addEventListener('hashchange', router);
-
+
+ // Update tab title on track change
+ audioPlayer.addEventListener('play', () => {
+ updateTabTitle(player);
+ });
+
+ // Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
- .then(reg => console.log('Service worker registered'))
+ .then(reg => {
+ console.log('Service worker registered');
+
+ // Check for updates
+ reg.addEventListener('updatefound', () => {
+ const newWorker = reg.installing;
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ showUpdateNotification();
+ }
+ });
+ });
+ })
.catch(err => console.log('Service worker not registered', err));
});
}
-
+
+ // Install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
+ showInstallPrompt(deferredPrompt);
});
-});
\ No newline at end of file
+
+ // Show keyboard shortcuts on first visit
+ if (!localStorage.getItem('shortcuts-shown')) {
+ setTimeout(() => {
+ showKeyboardShortcuts();
+ localStorage.setItem('shortcuts-shown', 'true');
+ }, 3000);
+ }
+});
+
+function showUpdateNotification() {
+ const notification = document.createElement('div');
+ notification.className = 'update-notification';
+ notification.innerHTML = `
+
+
Update Available
+
A new version of Monochrome is available.
+
+
Update Now
+ `;
+ document.body.appendChild(notification);
+}
+
+function showInstallPrompt(deferredPrompt) {
+ if (!deferredPrompt) return;
+
+ const notification = document.createElement('div');
+ notification.className = 'install-prompt';
+ notification.innerHTML = `
+
+
Install Monochrome
+
Install this app for a better experience.
+
+
+ Install
+ Dismiss
+
+ `;
+ document.body.appendChild(notification);
+
+ document.getElementById('install-btn').addEventListener('click', async () => {
+ notification.remove();
+ deferredPrompt.prompt();
+ const { outcome } = await deferredPrompt.userChoice;
+ console.log(`User response to install prompt: ${outcome}`);
+ deferredPrompt = null;
+ });
+
+ document.getElementById('dismiss-install').addEventListener('click', () => {
+ notification.remove();
+ });
+}
+
+function showKeyboardShortcuts() {
+ const modal = document.createElement('div');
+ modal.className = 'shortcuts-modal-overlay';
+ modal.innerHTML = `
+
+
+
+
+ Space
+ Play / Pause
+
+
+ →
+ Seek forward 10s
+
+
+ ←
+ Seek backward 10s
+
+
+ Shift + →
+ Next track
+
+
+ Shift + ←
+ Previous track
+
+
+ ↑
+ Volume up
+
+
+ ↓
+ Volume down
+
+
+ M
+ Mute / Unmute
+
+
+ S
+ Toggle shuffle
+
+
+ R
+ Toggle repeat
+
+
+ Q
+ Open queue
+
+
+ L
+ Toggle lyrics
+
+
+ /
+ Focus search
+
+
+ Esc
+ Close modals
+
+
+
+ `;
+ document.body.appendChild(modal);
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal || e.target.classList.contains('close-shortcuts')) {
+ modal.remove();
+ }
+ });
+}
\ No newline at end of file
diff --git a/js/downloads.js b/js/downloads.js
new file mode 100644
index 0000000..eede5be
--- /dev/null
+++ b/js/downloads.js
@@ -0,0 +1,407 @@
+import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate } from './utils.js';
+import { lyricsSettings } from './storage.js';
+
+const downloadTasks = new Map();
+let downloadNotificationContainer = null;
+
+async function loadJSZip() {
+ try {
+ const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
+ return module.default;
+ } catch (error) {
+ console.error('Failed to load JSZip:', error);
+ throw new Error('Failed to load ZIP library');
+ }
+}
+
+function createDownloadNotification() {
+ if (!downloadNotificationContainer) {
+ downloadNotificationContainer = document.createElement('div');
+ downloadNotificationContainer.id = 'download-notifications';
+ document.body.appendChild(downloadNotificationContainer);
+ }
+ return downloadNotificationContainer;
+}
+
+export function addDownloadTask(trackId, track, filename, api) {
+ const container = createDownloadNotification();
+
+ const taskEl = document.createElement('div');
+ taskEl.className = 'download-task';
+ taskEl.dataset.trackId = trackId;
+ const trackTitle = getTrackTitle(track);
+ taskEl.innerHTML = `
+
+
+
+
${trackTitle}
+
${track.artist?.name || 'Unknown'}
+
+
Starting...
+
+
+
+
+
+
+
+
+ `;
+
+ container.appendChild(taskEl);
+
+ const abortController = new AbortController();
+ downloadTasks.set(trackId, { taskEl, abortController });
+
+ taskEl.querySelector('.download-cancel').addEventListener('click', () => {
+ abortController.abort();
+ removeDownloadTask(trackId);
+ });
+
+ return { taskEl, abortController };
+}
+
+export function updateDownloadProgress(trackId, progress) {
+ const task = downloadTasks.get(trackId);
+ if (!task) return;
+
+ const { taskEl } = task;
+ const progressFill = taskEl.querySelector('.download-progress-fill');
+ const statusEl = taskEl.querySelector('.download-status');
+
+ if (progress.stage === 'downloading') {
+ const percent = progress.totalBytes
+ ? Math.round((progress.receivedBytes / progress.totalBytes) * 100)
+ : 0;
+
+ progressFill.style.width = `${percent}%`;
+
+ const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
+ const totalMB = progress.totalBytes
+ ? (progress.totalBytes / (1024 * 1024)).toFixed(1)
+ : '?';
+
+ statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
+ }
+}
+
+export function completeDownloadTask(trackId, success = true, message = null) {
+ const task = downloadTasks.get(trackId);
+ if (!task) return;
+
+ const { taskEl } = task;
+ const progressFill = taskEl.querySelector('.download-progress-fill');
+ const statusEl = taskEl.querySelector('.download-status');
+ const cancelBtn = taskEl.querySelector('.download-cancel');
+
+ if (success) {
+ progressFill.style.width = '100%';
+ progressFill.style.background = '#10b981';
+ statusEl.textContent = '✓ Downloaded';
+ statusEl.style.color = '#10b981';
+ cancelBtn.remove();
+
+ setTimeout(() => removeDownloadTask(trackId), 3000);
+ } else {
+ progressFill.style.background = '#ef4444';
+ statusEl.textContent = message || '✗ Download failed';
+ statusEl.style.color = '#ef4444';
+ cancelBtn.innerHTML = `
+
+
+
+
+ `;
+ cancelBtn.onclick = () => removeDownloadTask(trackId);
+
+ setTimeout(() => removeDownloadTask(trackId), 5000);
+ }
+}
+
+function removeDownloadTask(trackId) {
+ const task = downloadTasks.get(trackId);
+ if (!task) return;
+
+ const { taskEl } = task;
+ taskEl.style.animation = 'slideOut 0.3s ease';
+
+ setTimeout(() => {
+ taskEl.remove();
+ downloadTasks.delete(trackId);
+
+ if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
+ downloadNotificationContainer.remove();
+ downloadNotificationContainer = null;
+ }
+ }, 300);
+}
+
+async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
+ const lookup = await api.getTrack(track.id, quality);
+ let streamUrl;
+
+ if (lookup.originalTrackUrl) {
+ streamUrl = lookup.originalTrackUrl;
+ } else {
+ streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
+ if (!streamUrl) {
+ throw new Error('Could not resolve stream URL');
+ }
+ }
+
+ const response = await fetch(streamUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch track: ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ return blob;
+}
+
+export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
+ const JSZip = await loadJSZip();
+ const zip = new JSZip();
+
+ const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
+ const folderName = formatTemplate(template, {
+ albumTitle: album.title,
+ albumArtist: album.artist?.name,
+ year: new Date(album.releaseDate).getFullYear()
+ });
+
+ const notification = createBulkDownloadNotification('album', album.title, tracks.length);
+
+ try {
+ for (let i = 0; i < tracks.length; i++) {
+ const track = tracks[i];
+ const filename = buildTrackFilename(track, quality);
+ const trackTitle = getTrackTitle(track);
+
+ updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
+
+ const blob = await downloadTrackBlob(track, quality, api);
+ zip.file(`${folderName}/${filename}`, blob);
+
+ // Add LRC to zip if enabled
+ if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
+ try {
+ const lyricsData = await lyricsManager.fetchLyrics(track.id);
+ if (lyricsData) {
+ const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
+ if (lrcContent) {
+ const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
+ zip.file(`${folderName}/${lrcFilename}`, lrcContent);
+ }
+ }
+ } catch (error) {
+ console.log('Could not add lyrics for:', trackTitle);
+ }
+ }
+ }
+
+ updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...');
+
+ const zipBlob = await zip.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 6 }
+ });
+
+ const url = URL.createObjectURL(zipBlob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${folderName}.zip`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ completeBulkDownload(notification, true);
+ } catch (error) {
+ completeBulkDownload(notification, false, error.message);
+ throw error;
+ }
+}
+
+export async function downloadDiscography(artist, api, quality, lyricsManager = null) {
+ const JSZip = await loadJSZip();
+ const zip = new JSZip();
+
+ const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
+ const rootFolder = `${sanitizeForFilename(artist.name)} discography - monochrome.tf`;
+
+ const totalAlbums = artist.albums.length;
+ const notification = createBulkDownloadNotification('discography', artist.name, totalAlbums);
+
+ try {
+ for (let albumIndex = 0; albumIndex < artist.albums.length; albumIndex++) {
+ const album = artist.albums[albumIndex];
+
+ updateBulkDownloadProgress(notification, albumIndex, totalAlbums, album.title);
+
+ try {
+ const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
+ const albumFolder = formatTemplate(template, {
+ albumTitle: fullAlbum.title,
+ albumArtist: fullAlbum.artist?.name,
+ year: new Date(fullAlbum.releaseDate).getFullYear()
+ });
+
+ for (const track of tracks) {
+ const filename = buildTrackFilename(track, quality);
+ const blob = await downloadTrackBlob(track, quality, api);
+ zip.file(`${rootFolder}/${albumFolder}/${filename}`, blob);
+
+ // Add LRC to zip if enabled
+ if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
+ try {
+ const lyricsData = await lyricsManager.fetchLyrics(track.id);
+ if (lyricsData) {
+ const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
+ if (lrcContent) {
+ const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
+ zip.file(`${rootFolder}/${albumFolder}/${lrcFilename}`, lrcContent);
+ }
+ }
+ } catch (error) {
+ console.log('Could not add lyrics for:', track.title);
+ }
+ }
+ }
+ } catch (error) {
+ console.error(`Failed to download album ${album.title}:`, error);
+ }
+ }
+
+ updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...');
+
+ const zipBlob = await zip.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 6 }
+ });
+
+ const url = URL.createObjectURL(zipBlob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${rootFolder}.zip`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ completeBulkDownload(notification, true);
+ } catch (error) {
+ completeBulkDownload(notification, false, error.message);
+ throw error;
+ }
+}
+
+function createBulkDownloadNotification(type, name, totalItems) {
+ const container = createDownloadNotification();
+
+ const notifEl = document.createElement('div');
+ notifEl.className = 'download-task bulk-download';
+
+ notifEl.innerHTML = `
+
+
+
+ Downloading ${type === 'album' ? 'Album' : 'Discography'}
+
+
${name}
+
+
Starting...
+
+
+ `;
+
+ container.appendChild(notifEl);
+ return notifEl;
+}
+
+function updateBulkDownloadProgress(notifEl, current, total, currentItem) {
+ const progressFill = notifEl.querySelector('.download-progress-fill');
+ const statusEl = notifEl.querySelector('.download-status');
+
+ const percent = total > 0 ? Math.round((current / total) * 100) : 0;
+ progressFill.style.width = `${percent}%`;
+ statusEl.textContent = `${current}/${total} - ${currentItem}`;
+}
+
+function completeBulkDownload(notifEl, success = true, message = null) {
+ const progressFill = notifEl.querySelector('.download-progress-fill');
+ const statusEl = notifEl.querySelector('.download-status');
+
+ if (success) {
+ progressFill.style.width = '100%';
+ progressFill.style.background = '#10b981';
+ statusEl.textContent = '✓ Download complete';
+ statusEl.style.color = '#10b981';
+
+ setTimeout(() => {
+ notifEl.style.animation = 'slideOut 0.3s ease';
+ setTimeout(() => notifEl.remove(), 300);
+ }, 3000);
+ } else {
+ progressFill.style.background = '#ef4444';
+ statusEl.textContent = message || '✗ Download failed';
+ statusEl.style.color = '#ef4444';
+
+ setTimeout(() => {
+ notifEl.style.animation = 'slideOut 0.3s ease';
+ setTimeout(() => notifEl.remove(), 300);
+ }, 5000);
+ }
+}
+
+export async function downloadCurrentTrack(track, quality, api, lyricsManager = null) {
+ if (!track) {
+ alert('No track is currently playing');
+ return;
+ }
+
+ const filename = buildTrackFilename(track, quality);
+
+ try {
+ const { taskEl, abortController } = addDownloadTask(
+ track.id,
+ track,
+ filename,
+ api
+ );
+
+ await api.downloadTrack(track.id, quality, filename, {
+ signal: abortController.signal,
+ onProgress: (progress) => {
+ updateDownloadProgress(track.id, progress);
+ }
+ });
+
+ completeDownloadTask(track.id, true);
+
+ // Download LRC if enabled
+ if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
+ try {
+ const lyricsData = await lyricsManager.fetchLyrics(track.id);
+ if (lyricsData) {
+ lyricsManager.downloadLRC(lyricsData, track);
+ }
+ } catch (error) {
+ console.log('Could not download lyrics for track');
+ }
+ }
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
+ ? error.message
+ : 'Download failed. Please try again.';
+ completeDownloadTask(track.id, false, errorMsg);
+ }
+ }
+}
\ No newline at end of file
diff --git a/js/events.js b/js/events.js
new file mode 100644
index 0000000..c86176a
--- /dev/null
+++ b/js/events.js
@@ -0,0 +1,388 @@
+import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename } from './utils.js';
+import { lastFMStorage } from './storage.js';
+import { addDownloadTask, updateDownloadProgress, completeDownloadTask } from './downloads.js';
+import { updateTabTitle } from './router.js';
+
+export function initializePlayerEvents(player, audioPlayer, scrobbler) {
+ const playPauseBtn = document.querySelector('.play-pause-btn');
+ const nextBtn = document.getElementById('next-btn');
+ const prevBtn = document.getElementById('prev-btn');
+ const shuffleBtn = document.getElementById('shuffle-btn');
+ const repeatBtn = document.getElementById('repeat-btn');
+
+ audioPlayer.addEventListener('play', () => {
+ if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
+ scrobbler.updateNowPlaying(player.currentTrack);
+ }
+ playPauseBtn.innerHTML = SVG_PAUSE;
+ player.updateMediaSessionPlaybackState();
+ updateTabTitle(player);
+ });
+
+ audioPlayer.addEventListener('pause', () => {
+ playPauseBtn.innerHTML = SVG_PLAY;
+ player.updateMediaSessionPlaybackState();
+ });
+
+ audioPlayer.addEventListener('ended', () => {
+ player.playNext();
+ });
+
+ audioPlayer.addEventListener('timeupdate', () => {
+ const { currentTime, duration } = audioPlayer;
+ if (duration) {
+ const progressFill = document.getElementById('progress-fill');
+ const currentTimeEl = document.getElementById('current-time');
+ progressFill.style.width = `${(currentTime / duration) * 100}%`;
+ currentTimeEl.textContent = formatTime(currentTime);
+ player.updateMediaSessionPositionState();
+ }
+ });
+
+ audioPlayer.addEventListener('loadedmetadata', () => {
+ const totalDurationEl = document.getElementById('total-duration');
+ totalDurationEl.textContent = formatTime(audioPlayer.duration);
+ player.updateMediaSessionPositionState();
+ });
+
+ audioPlayer.addEventListener('error', (e) => {
+ console.error('Audio playback error:', e);
+ document.querySelector('.now-playing-bar .artist').textContent = 'Playback error. Try another track.';
+ playPauseBtn.innerHTML = SVG_PLAY;
+ });
+
+ playPauseBtn.addEventListener('click', () => player.handlePlayPause());
+ nextBtn.addEventListener('click', () => player.playNext());
+ prevBtn.addEventListener('click', () => player.playPrev());
+
+ shuffleBtn.addEventListener('click', () => {
+ player.toggleShuffle();
+ shuffleBtn.classList.toggle('active', player.shuffleActive);
+ renderQueue(player);
+ });
+
+ repeatBtn.addEventListener('click', () => {
+ const mode = player.toggleRepeat();
+ repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
+ repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
+ repeatBtn.title = mode === REPEAT_MODE.OFF
+ ? 'Repeat'
+ : (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
+ });
+
+ // Volume controls
+ const volumeBar = document.getElementById('volume-bar');
+ const volumeFill = document.getElementById('volume-fill');
+ const volumeBtn = document.getElementById('volume-btn');
+
+ const updateVolumeUI = () => {
+ const { volume, muted } = audioPlayer;
+ volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
+ const effectiveVolume = muted ? 0 : volume * 100;
+ volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
+ };
+
+ volumeBtn.addEventListener('click', () => {
+ audioPlayer.muted = !audioPlayer.muted;
+ });
+
+ audioPlayer.addEventListener('volumechange', updateVolumeUI);
+
+ // Initialize volume from localStorage
+ const savedVolume = parseFloat(localStorage.getItem('volume') || '0.7');
+ audioPlayer.volume = savedVolume;
+ volumeFill.style.width = `${savedVolume * 100}%`;
+ volumeBar.style.setProperty('--volume-level', `${savedVolume * 100}%`);
+ updateVolumeUI();
+
+ initializeSmoothSliders(audioPlayer, player);
+}
+
+function initializeSmoothSliders(audioPlayer, player) {
+ const progressBar = document.getElementById('progress-bar');
+ const progressFill = document.getElementById('progress-fill');
+ const volumeBar = document.getElementById('volume-bar');
+ const volumeFill = document.getElementById('volume-fill');
+
+ let isSeeking = false;
+ let wasPlaying = false;
+ let isAdjustingVolume = false;
+
+ const seek = (bar, event, setter) => {
+ const rect = bar.getBoundingClientRect();
+ const position = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ setter(position);
+ };
+
+ // Progress bar with smooth dragging
+ progressBar.addEventListener('mousedown', (e) => {
+ isSeeking = true;
+ wasPlaying = !audioPlayer.paused;
+ if (wasPlaying) audioPlayer.pause();
+
+ seek(progressBar, e, position => {
+ if (!isNaN(audioPlayer.duration)) {
+ audioPlayer.currentTime = position * audioPlayer.duration;
+ progressFill.style.width = `${position * 100}%`;
+ }
+ });
+ });
+
+ // Touch events for mobile
+ progressBar.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ isSeeking = true;
+ wasPlaying = !audioPlayer.paused;
+ if (wasPlaying) audioPlayer.pause();
+
+ const touch = e.touches[0];
+ const rect = progressBar.getBoundingClientRect();
+ const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
+ if (!isNaN(audioPlayer.duration)) {
+ audioPlayer.currentTime = position * audioPlayer.duration;
+ progressFill.style.width = `${position * 100}%`;
+ }
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (isSeeking) {
+ seek(progressBar, e, position => {
+ if (!isNaN(audioPlayer.duration)) {
+ audioPlayer.currentTime = position * audioPlayer.duration;
+ progressFill.style.width = `${position * 100}%`;
+ }
+ });
+ }
+
+ if (isAdjustingVolume) {
+ seek(volumeBar, e, position => {
+ audioPlayer.volume = position;
+ volumeFill.style.width = `${position * 100}%`;
+ volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
+ localStorage.setItem('volume', position);
+ });
+ }
+ });
+
+ document.addEventListener('touchmove', (e) => {
+ if (isSeeking) {
+ const touch = e.touches[0];
+ const rect = progressBar.getBoundingClientRect();
+ const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
+ if (!isNaN(audioPlayer.duration)) {
+ audioPlayer.currentTime = position * audioPlayer.duration;
+ progressFill.style.width = `${position * 100}%`;
+ }
+ }
+
+ if (isAdjustingVolume) {
+ const touch = e.touches[0];
+ const rect = volumeBar.getBoundingClientRect();
+ const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
+ audioPlayer.volume = position;
+ volumeFill.style.width = `${position * 100}%`;
+ volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
+ localStorage.setItem('volume', position);
+ }
+ });
+
+ document.addEventListener('mouseup', (e) => {
+ if (isSeeking) {
+ seek(progressBar, e, position => {
+ if (!isNaN(audioPlayer.duration)) {
+ audioPlayer.currentTime = position * audioPlayer.duration;
+ player.updateMediaSessionPositionState();
+ if (wasPlaying) audioPlayer.play();
+ }
+ });
+ isSeeking = false;
+ }
+
+ if (isAdjustingVolume) {
+ isAdjustingVolume = false;
+ }
+ });
+
+ document.addEventListener('touchend', (e) => {
+ if (isSeeking) {
+ if (!isNaN(audioPlayer.duration)) {
+ player.updateMediaSessionPositionState();
+ if (wasPlaying) audioPlayer.play();
+ }
+ isSeeking = false;
+ }
+
+ if (isAdjustingVolume) {
+ isAdjustingVolume = false;
+ }
+ });
+
+ progressBar.addEventListener('click', e => {
+ if (!isSeeking) {
+ seek(progressBar, e, position => {
+ if (!isNaN(audioPlayer.duration)) {
+ audioPlayer.currentTime = position * audioPlayer.duration;
+ player.updateMediaSessionPositionState();
+ }
+ });
+ }
+ });
+
+ volumeBar.addEventListener('mousedown', (e) => {
+ isAdjustingVolume = true;
+ seek(volumeBar, e, position => {
+ audioPlayer.volume = position;
+ volumeFill.style.width = `${position * 100}%`;
+ volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
+ localStorage.setItem('volume', position);
+ });
+ });
+
+ volumeBar.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ isAdjustingVolume = true;
+ const touch = e.touches[0];
+ const rect = volumeBar.getBoundingClientRect();
+ const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
+ audioPlayer.volume = position;
+ volumeFill.style.width = `${position * 100}%`;
+ volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
+ localStorage.setItem('volume', position);
+ });
+
+ volumeBar.addEventListener('click', e => {
+ if (!isAdjustingVolume) {
+ seek(volumeBar, e, position => {
+ audioPlayer.volume = position;
+ volumeFill.style.width = `${position * 100}%`;
+ volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
+ localStorage.setItem('volume', position);
+ });
+ }
+ });
+}
+
+export function initializeTrackInteractions(player, api, mainContent, contextMenu) {
+ let contextTrack = null;
+
+ mainContent.addEventListener('click', e => {
+ const menuBtn = e.target.closest('.track-menu-btn');
+ if (menuBtn) {
+ e.stopPropagation();
+ const trackItem = menuBtn.closest('.track-item');
+ if (trackItem && !trackItem.dataset.queueIndex) {
+ contextTrack = trackDataStore.get(trackItem);
+ if (contextTrack) {
+ const rect = menuBtn.getBoundingClientRect();
+ contextMenu.style.top = `${rect.bottom + 5}px`;
+ contextMenu.style.left = `${rect.left}px`;
+ contextMenu.style.display = 'block';
+ }
+ }
+ return;
+ }
+
+ const trackItem = e.target.closest('.track-item');
+ if (trackItem && !trackItem.dataset.queueIndex) {
+ const parentList = trackItem.closest('.track-list');
+ const allTrackElements = Array.from(parentList.querySelectorAll('.track-item'));
+ const trackList = allTrackElements.map(el => trackDataStore.get(el)).filter(Boolean);
+
+ if (trackList.length > 0) {
+ const clickedTrackId = trackItem.dataset.trackId;
+ const startIndex = trackList.findIndex(t => t.id == clickedTrackId);
+
+ player.setQueue(trackList, startIndex);
+ document.getElementById('shuffle-btn').classList.remove('active');
+ player.playTrackFromQueue();
+ }
+ }
+ });
+
+ mainContent.addEventListener('contextmenu', e => {
+ const trackItem = e.target.closest('.track-item');
+ if (trackItem && !trackItem.dataset.queueIndex) {
+ e.preventDefault();
+ contextTrack = trackDataStore.get(trackItem);
+
+ if (contextTrack) {
+ contextMenu.style.top = `${e.pageY}px`;
+ contextMenu.style.left = `${e.pageX}px`;
+ contextMenu.style.display = 'block';
+ }
+ }
+ });
+
+ document.addEventListener('click', () => {
+ contextMenu.style.display = 'none';
+ });
+
+ contextMenu.addEventListener('click', async e => {
+ e.stopPropagation();
+ const action = e.target.dataset.action;
+
+ if (action === 'add-to-queue' && contextTrack) {
+ player.addToQueue(contextTrack);
+ renderQueue(player);
+ } else if (action === 'download' && contextTrack) {
+ const quality = player.quality;
+ const filename = buildTrackFilename(contextTrack, quality);
+
+ try {
+ const { taskEl, abortController } = addDownloadTask(
+ contextTrack.id,
+ contextTrack,
+ filename,
+ api
+ );
+
+ await api.downloadTrack(contextTrack.id, quality, filename, {
+ signal: abortController.signal,
+ onProgress: (progress) => {
+ updateDownloadProgress(contextTrack.id, progress);
+ }
+ });
+
+ completeDownloadTask(contextTrack.id, true);
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
+ ? error.message
+ : 'Download failed. Please try again.';
+ completeDownloadTask(contextTrack.id, false, errorMsg);
+ }
+ }
+ }
+
+ contextMenu.style.display = 'none';
+ });
+
+ // Now playing bar interactions
+ document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
+ const track = player.currentTrack;
+ if (track?.album?.id) {
+ window.location.hash = `#album/${track.album.id}`;
+ }
+ });
+
+ document.querySelector('.now-playing-bar .artist').addEventListener('click', () => {
+ const track = player.currentTrack;
+ if (track?.artist?.id) {
+ window.location.hash = `#artist/${track.artist.id}`;
+ }
+ });
+}
+
+function renderQueue(player) {
+ // This will be called from queue module
+ if (window.renderQueueFunction) {
+ window.renderQueueFunction();
+ }
+}
+
+function formatTime(seconds) {
+ if (isNaN(seconds)) return '0:00';
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
\ No newline at end of file
diff --git a/js/lyrics.js b/js/lyrics.js
new file mode 100644
index 0000000..c319c68
--- /dev/null
+++ b/js/lyrics.js
@@ -0,0 +1,213 @@
+import { getTrackTitle, getTrackArtists } from './utils.js';
+
+export class LyricsManager {
+ constructor(api) {
+ this.api = api;
+ this.currentLyrics = null;
+ this.syncedLyrics = [];
+ this.lyricsCache = new Map();
+ }
+
+ async fetchLyrics(trackId) {
+ if (this.lyricsCache.has(trackId)) {
+ return this.lyricsCache.get(trackId);
+ }
+
+ try {
+ const response = await this.api.fetchWithRetry(`/lyrics/?id=${trackId}`);
+ const data = await response.json();
+
+ if (Array.isArray(data) && data.length > 0) {
+ const lyricsData = data[0];
+ this.lyricsCache.set(trackId, lyricsData);
+ return lyricsData;
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Failed to fetch lyrics:', error);
+ return null;
+ }
+ }
+
+ parseSyncedLyrics(subtitles) {
+ if (!subtitles) return [];
+
+ const lines = subtitles.split('\n').filter(line => line.trim());
+ return lines.map(line => {
+ const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
+ if (match) {
+ const [, minutes, seconds, centiseconds, text] = match;
+ const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
+ return { time: timeInSeconds, text: text.trim() };
+ }
+ return null;
+ }).filter(Boolean);
+ }
+
+ generateLRCContent(lyricsData, track) {
+ if (!lyricsData || !lyricsData.subtitles) return null;
+
+ const trackTitle = getTrackTitle(track);
+ const trackArtist = getTrackArtists(track);
+
+ let lrc = `[ti:${trackTitle}]\n`;
+ lrc += `[ar:${trackArtist}]\n`;
+ lrc += `[al:${track.album?.title || 'Unknown Album'}]\n`;
+ lrc += `[by:${lyricsData.lyricsProvider || 'Unknown'}]\n`;
+ lrc += '\n';
+ lrc += lyricsData.subtitles;
+
+ return lrc;
+ }
+
+ downloadLRC(lyricsData, track) {
+ const lrcContent = this.generateLRCContent(lyricsData, track);
+ if (!lrcContent) {
+ alert('No synced lyrics available for this track');
+ return;
+ }
+
+ const blob = new Blob([lrcContent], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${getTrackArtists(track)} - ${getTrackTitle(track)}.lrc`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
+ getCurrentLine(currentTime) {
+ if (!this.syncedLyrics || this.syncedLyrics.length === 0) return -1;
+
+ let currentIndex = -1;
+ for (let i = 0; i < this.syncedLyrics.length; i++) {
+ if (currentTime >= this.syncedLyrics[i].time) {
+ currentIndex = i;
+ } else {
+ break;
+ }
+ }
+ return currentIndex;
+ }
+}
+
+export function createLyricsPanel() {
+ const panel = document.createElement('div');
+ panel.id = 'lyrics-panel';
+ panel.className = 'lyrics-panel hidden';
+ panel.innerHTML = `
+
+
+ `;
+ document.body.appendChild(panel);
+ return panel;
+}
+
+export function showKaraokeView(track, lyricsData, audioPlayer) {
+ const view = document.createElement('div');
+ view.id = 'karaoke-view';
+ view.className = 'karaoke-view';
+
+ const syncedLyrics = lyricsData.subtitles
+ ? parseSyncedLyricsSimple(lyricsData.subtitles)
+ : [];
+
+ view.innerHTML = `
+
+
+
${getTrackTitle(track)}
+
${getTrackArtists(track)}
+
+
+ `;
+
+ document.body.appendChild(view);
+
+ const lyricsContainer = view.querySelector('#karaoke-lyrics');
+ syncedLyrics.forEach((line, index) => {
+ const lineEl = document.createElement('div');
+ lineEl.className = 'karaoke-line';
+ lineEl.textContent = line.text;
+ lineEl.dataset.index = index;
+ lineEl.dataset.time = line.time;
+ lyricsContainer.appendChild(lineEl);
+ });
+
+ let updateInterval = setInterval(() => {
+ const currentTime = audioPlayer.currentTime;
+ const currentIndex = getCurrentLineIndex(syncedLyrics, currentTime);
+
+ document.querySelectorAll('.karaoke-line').forEach((line, index) => {
+ line.classList.toggle('active', index === currentIndex);
+ line.classList.toggle('past', index < currentIndex);
+ });
+
+ if (currentIndex >= 0) {
+ const activeLine = lyricsContainer.children[currentIndex];
+ if (activeLine) {
+ activeLine.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ }
+ }, 100);
+
+ view.querySelector('#close-karaoke-btn').addEventListener('click', () => {
+ clearInterval(updateInterval);
+ view.remove();
+ });
+
+ return view;
+}
+
+function parseSyncedLyricsSimple(subtitles) {
+ const lines = subtitles.split('\n').filter(line => line.trim());
+ return lines.map(line => {
+ const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
+ if (match) {
+ const [, minutes, seconds, centiseconds, text] = match;
+ const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
+ return { time: timeInSeconds, text };
+ }
+ return null;
+ }).filter(Boolean);
+}
+
+function getCurrentLineIndex(syncedLyrics, currentTime) {
+ let currentIndex = -1;
+ for (let i = 0; i < syncedLyrics.length; i++) {
+ if (currentTime >= syncedLyrics[i].time) {
+ currentIndex = i;
+ } else {
+ break;
+ }
+ }
+ return currentIndex;
+}
\ No newline at end of file
diff --git a/js/player.js b/js/player.js
index 6045879..a526dbd 100644
--- a/js/player.js
+++ b/js/player.js
@@ -1,4 +1,3 @@
-//player.js
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle} from './utils.js';
export class Player {
@@ -15,23 +14,8 @@ export class Player {
this.preloadCache = new Map();
this.preloadAbortController = null;
this.currentTrack = null;
- this.crossfadeEnabled = false;
- this.crossfadeDuration = 5;
- this.nextAudioElement = null;
- this.isCrossfading = false;
this.setupMediaSession();
- this.setupCrossfade();
- }
-
- setupCrossfade() {
- this.nextAudioElement = document.createElement('audio');
- this.nextAudioElement.preload = 'auto';
- }
-
- setCrossfade(enabled, duration = 5) {
- this.crossfadeEnabled = enabled;
- this.crossfadeDuration = Math.max(1, Math.min(12, duration));
}
setupMediaSession() {
@@ -97,7 +81,7 @@ export class Player {
}
}
- for (const { track, index } of tracksToPreload) {
+ for (const { track } of tracksToPreload) {
if (this.preloadCache.has(track.id)) continue;
const trackTitle = getTrackTitle(track);
try {
@@ -106,11 +90,6 @@ export class Player {
if (this.preloadAbortController.signal.aborted) break;
this.preloadCache.set(track.id, streamUrl);
-
- if (index === this.currentQueueIndex + 1 && this.crossfadeEnabled) {
- this.nextAudioElement.src = streamUrl;
- }
-
} catch (error) {
if (error.name !== 'AbortError') {
console.debug('Failed to get stream URL for preload:', trackTitle);
@@ -119,134 +98,51 @@ export class Player {
}
}
-async playTrackFromQueue() {
- const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
- if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
- return;
- }
-
- const track = currentQueue[this.currentQueueIndex];
- this.currentTrack = track;
-
- const trackTitle = getTrackTitle(track);
- const trackArtists = getTrackArtists(track);
-
- document.querySelector('.now-playing-bar .cover').src =
- this.api.getCoverUrl(track.album?.cover, '1280');
- document.querySelector('.now-playing-bar .title').textContent = trackTitle;
- document.querySelector('.now-playing-bar .artist').textContent = trackArtists;
- document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`;
-
- this.updatePlayingTrackIndicator();
- this.updateMediaSession(track);
-
- try {
- let streamUrl;
-
- if (this.preloadCache.has(track.id)) {
- streamUrl = this.preloadCache.get(track.id);
- } else {
- const trackData = await this.api.getTrack(track.id, this.quality);
-
- // Store replayGain for normalization
- if (trackData.track?.replayGain !== undefined) {
- window.currentGain = trackData.track.replayGain;
- } else {
- window.currentGain = track.replayGain || null;
- }
-
- if (trackData.originalTrackUrl) {
- streamUrl = trackData.originalTrackUrl;
- } else {
- streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
- }
- }
-
- if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
- const temp = this.audio;
- this.audio = this.nextAudioElement;
- this.nextAudioElement = temp;
-
- this.nextAudioElement.pause();
- this.nextAudioElement.currentTime = 0;
- } else {
- this.audio.src = streamUrl;
- }
-
- // Apply normalization if enabled
- this.applyNormalization();
-
- await this.audio.play();
- this.isCrossfading = false;
-
- this.updateMediaSessionPlaybackState();
- this.preloadNextTracks();
- this.setupCrossfadeListener();
-
- } catch (error) {
- console.error(`Could not play track: ${trackTitle}`, error);
- document.querySelector('.now-playing-bar .title').textContent = `Error: ${trackTitle}`;
- document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
- }
-}
-
- setupCrossfadeListener() {
- if (!this.crossfadeEnabled) return;
-
- const checkCrossfade = () => {
- const timeRemaining = this.audio.duration - this.audio.currentTime;
-
- if (timeRemaining <= this.crossfadeDuration && timeRemaining > 0 && !this.isCrossfading) {
- this.startCrossfade();
- }
- };
-
- this.audio.removeEventListener('timeupdate', this.crossfadeCheck);
- this.crossfadeCheck = checkCrossfade;
- this.audio.addEventListener('timeupdate', this.crossfadeCheck);
- }
-
- async startCrossfade() {
- if (this.repeatMode === REPEAT_MODE.ONE) return;
-
+ async playTrackFromQueue() {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
- const nextIndex = this.currentQueueIndex + 1;
+ if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
+ return;
+ }
+
+ const track = currentQueue[this.currentQueueIndex];
+ this.currentTrack = track;
+
+ const trackTitle = getTrackTitle(track);
+ const trackArtists = getTrackArtists(track);
- if (nextIndex >= currentQueue.length && this.repeatMode !== REPEAT_MODE.ALL) return;
+ document.querySelector('.now-playing-bar .cover').src =
+ this.api.getCoverUrl(track.album?.cover, '1280');
+ document.querySelector('.now-playing-bar .title').textContent = trackTitle;
+ document.querySelector('.now-playing-bar .artist').textContent = trackArtists;
+ document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`;
- this.isCrossfading = true;
- const targetIndex = nextIndex >= currentQueue.length ? 0 : nextIndex;
- const nextTrack = currentQueue[targetIndex];
-
- if (this.nextAudioElement.src && this.preloadCache.has(nextTrack.id)) {
- try {
- await this.nextAudioElement.play();
- this.nextAudioElement.volume = 0;
+ this.updatePlayingTrackIndicator();
+ this.updateMediaSession(track);
+
+ try {
+ let streamUrl;
+
+ if (this.preloadCache.has(track.id)) {
+ streamUrl = this.preloadCache.get(track.id);
+ } else {
+ const trackData = await this.api.getTrack(track.id, this.quality);
- const fadeSteps = 20;
- const fadeInterval = (this.crossfadeDuration * 1000) / fadeSteps;
-
- let step = 0;
- const fadeTimer = setInterval(() => {
- step++;
- const progress = step / fadeSteps;
-
- this.audio.volume = Math.max(0, 1 - progress);
- this.nextAudioElement.volume = Math.min(1, progress);
-
- if (step >= fadeSteps) {
- clearInterval(fadeTimer);
- this.audio.pause();
- this.audio.volume = 1;
- this.currentQueueIndex = targetIndex;
- this.playTrackFromQueue();
- }
- }, fadeInterval);
-
- } catch (error) {
- console.error('Crossfade failed:', error);
- this.isCrossfading = false;
+ if (trackData.originalTrackUrl) {
+ streamUrl = trackData.originalTrackUrl;
+ } else {
+ streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
+ }
}
+
+ this.audio.src = streamUrl;
+ await this.audio.play();
+
+ this.updateMediaSessionPlaybackState();
+ this.preloadNextTracks();
+ } catch (error) {
+ console.error(`Could not play track: ${trackTitle}`, error);
+ document.querySelector('.now-playing-bar .title').textContent = `Error: ${trackTitle}`;
+ document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
}
}
@@ -435,16 +331,6 @@ async playTrackFromQueue() {
this.updateMediaSessionPlaybackState();
this.updateMediaSessionPositionState();
}
-applyNormalization() {
- const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true';
-
- if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) {
- const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7');
- const replayGain = parseFloat(window.currentGain);
- const adjustment = Math.pow(10, replayGain / 20);
- this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment));
- }
-}
updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return;
diff --git a/js/router.js b/js/router.js
new file mode 100644
index 0000000..b5891c3
--- /dev/null
+++ b/js/router.js
@@ -0,0 +1,40 @@
+export function createRouter(ui) {
+ const router = () => {
+ const path = window.location.hash.substring(1) || "home";
+ const [page, param] = path.split('/');
+
+ switch (page) {
+ case 'search':
+ ui.renderSearchPage(decodeURIComponent(param));
+ break;
+ case 'album':
+ ui.renderAlbumPage(param);
+ break;
+ case 'artist':
+ ui.renderArtistPage(param);
+ break;
+ case 'home':
+ ui.renderHomePage();
+ break;
+ default:
+ ui.showPage(page);
+ break;
+ }
+ };
+
+ return router;
+}
+
+export function updateTabTitle(player) {
+ if (player.currentTrack) {
+ const track = player.currentTrack;
+ document.title = `${track.title} • ${track.artist?.name || 'Unknown'} - Monochrome`;
+ } else {
+ const hash = window.location.hash;
+ if (hash.includes('#album/')) {
+ // Will be updated by album render
+ return;
+ }
+ document.title = 'Monochrome Music';
+ }
+}
diff --git a/js/settings.js b/js/settings.js
new file mode 100644
index 0000000..994906b
--- /dev/null
+++ b/js/settings.js
@@ -0,0 +1,273 @@
+import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings } from './storage.js';
+
+export function initializeSettings(scrobbler, player, api, ui) {
+ const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
+ const lastfmStatus = document.getElementById('lastfm-status');
+ const lastfmToggle = document.getElementById('lastfm-toggle');
+ const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
+
+ function updateLastFMUI() {
+ if (scrobbler.isAuthenticated()) {
+ lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
+ lastfmConnectBtn.textContent = 'Disconnect';
+ lastfmConnectBtn.classList.add('danger');
+ lastfmToggleSetting.style.display = 'flex';
+ lastfmToggle.checked = lastFMStorage.isEnabled();
+ } else {
+ lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
+ lastfmConnectBtn.textContent = 'Connect Last.fm';
+ lastfmConnectBtn.classList.remove('danger');
+ lastfmToggleSetting.style.display = 'none';
+ }
+ }
+
+ updateLastFMUI();
+
+ lastfmConnectBtn?.addEventListener('click', async () => {
+ if (scrobbler.isAuthenticated()) {
+ if (confirm('Disconnect from Last.fm?')) {
+ scrobbler.disconnect();
+ updateLastFMUI();
+ }
+ return;
+ }
+
+ const authWindow = window.open('', '_blank');
+ lastfmConnectBtn.disabled = true;
+ lastfmConnectBtn.textContent = 'Opening Last.fm...';
+
+ try {
+ const { token, url } = await scrobbler.getAuthUrl();
+
+ if (authWindow) {
+ authWindow.location.href = url;
+ } else {
+ alert('Popup blocked! Please allow popups.');
+ lastfmConnectBtn.textContent = 'Connect Last.fm';
+ lastfmConnectBtn.disabled = false;
+ return;
+ }
+
+ lastfmConnectBtn.textContent = 'Waiting for authorization...';
+
+ let attempts = 0;
+ const maxAttempts = 30;
+
+ const checkAuth = setInterval(async () => {
+ attempts++;
+
+ if (attempts > maxAttempts) {
+ clearInterval(checkAuth);
+ lastfmConnectBtn.textContent = 'Connect Last.fm';
+ lastfmConnectBtn.disabled = false;
+ if (authWindow && !authWindow.closed) authWindow.close();
+ alert('Authorization timed out. Please try again.');
+ return;
+ }
+
+ try {
+ const result = await scrobbler.completeAuthentication(token);
+
+ if (result.success) {
+ clearInterval(checkAuth);
+ if (authWindow && !authWindow.closed) authWindow.close();
+ updateLastFMUI();
+ lastfmConnectBtn.disabled = false;
+ lastFMStorage.setEnabled(true);
+ lastfmToggle.checked = true;
+ alert(`Successfully connected to Last.fm as ${result.username}!`);
+ }
+ } catch (e) {
+ // Still waiting
+ }
+ }, 2000);
+
+ } catch (error) {
+ console.error('Last.fm connection failed:', error);
+ alert('Failed to connect to Last.fm: ' + error.message);
+ lastfmConnectBtn.textContent = 'Connect Last.fm';
+ lastfmConnectBtn.disabled = false;
+ if (authWindow && !authWindow.closed) authWindow.close();
+ }
+ });
+
+ lastfmToggle?.addEventListener('change', (e) => {
+ lastFMStorage.setEnabled(e.target.checked);
+ });
+
+ // Theme picker
+ const themePicker = document.getElementById('theme-picker');
+ const currentTheme = themeManager.getTheme();
+
+ themePicker.querySelectorAll('.theme-option').forEach(option => {
+ if (option.dataset.theme === currentTheme) {
+ option.classList.add('active');
+ }
+
+ option.addEventListener('click', () => {
+ const theme = option.dataset.theme;
+
+ themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active'));
+ option.classList.add('active');
+
+ if (theme === 'custom') {
+ document.getElementById('custom-theme-editor').classList.add('show');
+ renderCustomThemeEditor();
+ } else {
+ document.getElementById('custom-theme-editor').classList.remove('show');
+ themeManager.setTheme(theme);
+ }
+ });
+ });
+
+ function renderCustomThemeEditor() {
+ const grid = document.getElementById('theme-color-grid');
+ const customTheme = themeManager.getCustomTheme() || {
+ background: '#000000',
+ foreground: '#fafafa',
+ primary: '#ffffff',
+ secondary: '#27272a',
+ muted: '#27272a',
+ border: '#27272a',
+ highlight: '#ffffff'
+ };
+
+ grid.innerHTML = Object.entries(customTheme).map(([key, value]) => `
+
+ ${key}
+
+
+ `).join('');
+ }
+
+ document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
+ const colors = {};
+ document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => {
+ colors[input.dataset.color] = input.value;
+ });
+ themeManager.setCustomTheme(colors);
+ });
+
+ document.getElementById('reset-custom-theme')?.addEventListener('click', () => {
+ renderCustomThemeEditor();
+ });
+
+ // Quality setting
+ const qualitySetting = document.getElementById('quality-setting');
+ if (qualitySetting) {
+ const savedQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
+ qualitySetting.value = savedQuality;
+ player.setQuality(savedQuality);
+
+ qualitySetting.addEventListener('change', (e) => {
+ const newQuality = e.target.value;
+ player.setQuality(newQuality);
+ localStorage.setItem('playback-quality', newQuality);
+ });
+ }
+
+ // Now Playing Mode
+ const nowPlayingMode = document.getElementById('now-playing-mode');
+ if (nowPlayingMode) {
+ nowPlayingMode.value = nowPlayingSettings.getMode();
+ nowPlayingMode.addEventListener('change', (e) => {
+ nowPlayingSettings.setMode(e.target.value);
+ });
+ }
+
+ // Download Lyrics Toggle
+ const downloadLyricsToggle = document.getElementById('download-lyrics-toggle');
+ if (downloadLyricsToggle) {
+ downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics();
+ downloadLyricsToggle.addEventListener('change', (e) => {
+ lyricsSettings.setDownloadLyrics(e.target.checked);
+ });
+ }
+
+ // Filename template setting
+ const filenameTemplate = document.getElementById('filename-template');
+ if (filenameTemplate) {
+ filenameTemplate.value = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
+ filenameTemplate.addEventListener('change', (e) => {
+ localStorage.setItem('filename-template', e.target.value);
+ });
+ }
+
+ // ZIP folder template
+ const zipFolderTemplate = document.getElementById('zip-folder-template');
+ if (zipFolderTemplate) {
+ zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
+ zipFolderTemplate.addEventListener('change', (e) => {
+ localStorage.setItem('zip-folder-template', e.target.value);
+ });
+ }
+
+ // API settings
+ document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
+ const btn = document.getElementById('refresh-speed-test-btn');
+ const originalText = btn.textContent;
+ btn.textContent = 'Testing...';
+ btn.disabled = true;
+
+ try {
+ await api.settings.refreshSpeedTests();
+ ui.renderApiSettings();
+ btn.textContent = 'Done!';
+ setTimeout(() => {
+ btn.textContent = originalText;
+ btn.disabled = false;
+ }, 1500);
+ } catch (error) {
+ console.error('Failed to refresh speed tests:', error);
+ btn.textContent = 'Error';
+ setTimeout(() => {
+ btn.textContent = originalText;
+ btn.disabled = false;
+ }, 1500);
+ }
+ });
+
+ document.getElementById('api-instance-list')?.addEventListener('click', async (e) => {
+ const button = e.target.closest('button');
+ if (!button) return;
+
+ const li = button.closest('li');
+ const index = parseInt(li.dataset.index, 10);
+ const instances = await api.settings.getInstances();
+
+ if (button.classList.contains('move-up') && index > 0) {
+ [instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
+ } else if (button.classList.contains('move-down') && index < instances.length - 1) {
+ [instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
+ }
+
+ api.settings.saveInstances(instances);
+ ui.renderApiSettings();
+ });
+
+ document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
+ const btn = document.getElementById('clear-cache-btn');
+ const originalText = btn.textContent;
+ btn.textContent = 'Clearing...';
+ btn.disabled = true;
+
+ try {
+ await api.clearCache();
+ btn.textContent = 'Cleared!';
+ setTimeout(() => {
+ btn.textContent = originalText;
+ btn.disabled = false;
+ if (window.location.hash.includes('settings')) {
+ ui.renderApiSettings();
+ }
+ }, 1500);
+ } catch (error) {
+ console.error('Failed to clear cache:', error);
+ btn.textContent = 'Error';
+ setTimeout(() => {
+ btn.textContent = originalText;
+ btn.disabled = false;
+ }, 1500);
+ }
+ });
+}
\ No newline at end of file
diff --git a/js/storage.js b/js/storage.js
index 6a6fa80..2989942 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -1,4 +1,3 @@
-//storage.js
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',
@@ -293,4 +292,36 @@ export const lastFMStorage = {
setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
}
+};
+
+export const nowPlayingSettings = {
+ STORAGE_KEY: 'now-playing-mode',
+
+ getMode() {
+ try {
+ return localStorage.getItem(this.STORAGE_KEY) || 'cover';
+ } catch (e) {
+ return 'cover';
+ }
+ },
+
+ setMode(mode) {
+ localStorage.setItem(this.STORAGE_KEY, mode);
+ }
+};
+
+export const lyricsSettings = {
+ DOWNLOAD_WITH_TRACKS: 'lyrics-download-with-tracks',
+
+ shouldDownloadLyrics() {
+ try {
+ return localStorage.getItem(this.DOWNLOAD_WITH_TRACKS) === 'true';
+ } catch (e) {
+ return false;
+ }
+ },
+
+ setDownloadLyrics(enabled) {
+ localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false');
+ }
};
\ No newline at end of file
diff --git a/js/ui-interactions.js b/js/ui-interactions.js
new file mode 100644
index 0000000..c27a2c4
--- /dev/null
+++ b/js/ui-interactions.js
@@ -0,0 +1,210 @@
+import { formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js';
+
+export function initializeUIInteractions(player, api) {
+ const sidebar = document.querySelector('.sidebar');
+ const sidebarOverlay = document.getElementById('sidebar-overlay');
+ const hamburgerBtn = document.getElementById('hamburger-btn');
+ const queueBtn = document.getElementById('queue-btn');
+ const queueModalOverlay = document.getElementById('queue-modal-overlay');
+ const closeQueueBtn = document.getElementById('close-queue-btn');
+ const queueList = document.getElementById('queue-list');
+
+ let draggedQueueIndex = null;
+
+ // Sidebar mobile
+ hamburgerBtn.addEventListener('click', () => {
+ sidebar.classList.add('is-open');
+ sidebarOverlay.classList.add('is-visible');
+ });
+
+ const closeSidebar = () => {
+ sidebar.classList.remove('is-open');
+ sidebarOverlay.classList.remove('is-visible');
+ };
+
+ sidebarOverlay.addEventListener('click', closeSidebar);
+
+ sidebar.addEventListener('click', e => {
+ if (e.target.closest('a')) {
+ closeSidebar();
+ }
+ });
+
+ // Queue modal
+ queueBtn.addEventListener('click', () => {
+ renderQueue();
+ queueModalOverlay.style.display = 'flex';
+ });
+
+ closeQueueBtn.addEventListener('click', () => {
+ queueModalOverlay.style.display = 'none';
+ });
+
+ queueModalOverlay.addEventListener('click', e => {
+ if (e.target === queueModalOverlay) {
+ queueModalOverlay.style.display = 'none';
+ }
+ });
+
+ function renderQueue() {
+ const currentQueue = player.getCurrentQueue();
+
+ if (currentQueue.length === 0) {
+ queueList.innerHTML = '
Queue is empty.
';
+ return;
+ }
+
+ const html = currentQueue.map((track, index) => {
+ const isPlaying = index === player.currentQueueIndex;
+ const trackTitle = getTrackTitle(track);
+ const trackArtists = getTrackArtists(track, { fallback: "Unknown" });
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
${trackTitle}
+
${trackArtists}
+
+
+
${formatTime(track.duration)}
+
+
+ `;
+ }).join('');
+
+ queueList.innerHTML = html;
+
+ queueList.querySelectorAll('.queue-track-item').forEach((item) => {
+ const index = parseInt(item.dataset.queueIndex);
+
+ item.addEventListener('click', (e) => {
+ if (e.target.closest('.track-menu-btn')) return;
+ player.playAtIndex(index);
+ renderQueue();
+ });
+
+ item.addEventListener('dragstart', (e) => {
+ draggedQueueIndex = index;
+ item.style.opacity = '0.5';
+ });
+
+ item.addEventListener('dragend', () => {
+ item.style.opacity = '1';
+ });
+
+ item.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ });
+
+ item.addEventListener('drop', (e) => {
+ e.preventDefault();
+ if (draggedQueueIndex !== null && draggedQueueIndex !== index) {
+ player.moveInQueue(draggedQueueIndex, index);
+ renderQueue();
+ }
+ });
+ });
+
+ queueList.querySelectorAll('.track-menu-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const index = parseInt(btn.dataset.trackIndex);
+ showQueueTrackMenu(e, index);
+ });
+ });
+ }
+
+ function showQueueTrackMenu(e, trackIndex) {
+ const menu = document.getElementById('queue-track-menu');
+ menu.style.top = `${e.pageY}px`;
+ menu.style.left = `${e.pageX}px`;
+ menu.classList.add('show');
+ menu.dataset.trackIndex = trackIndex;
+ positionContextMenu(menu, e.pageX, e.pageY, true);
+ document.addEventListener('click', hideQueueTrackMenu);
+ }
+
+ function hideQueueTrackMenu() {
+ const menu = document.getElementById('queue-track-menu');
+ menu.classList.remove('show');
+ document.removeEventListener('click', hideQueueTrackMenu);
+ }
+
+ document.getElementById('queue-track-menu').addEventListener('click', (e) => {
+ e.stopPropagation();
+ const action = e.target.dataset.action;
+ const menu = document.getElementById('queue-track-menu');
+ const trackIndex = parseInt(menu.dataset.trackIndex);
+
+ if (action === 'remove') {
+ player.removeFromQueue(trackIndex);
+ renderQueue();
+ }
+
+ hideQueueTrackMenu();
+ });
+
+ function positionContextMenu(menu, x, y, preferLeft = false) {
+ menu.style.display = 'block';
+ menu.style.visibility = 'hidden';
+
+ const menuRect = menu.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ let finalX = x;
+ let finalY = y;
+
+ if (preferLeft || (x + menuRect.width > viewportWidth)) {
+ finalX = x - menuRect.width;
+ if (finalX < 0) {
+ finalX = Math.min(x, viewportWidth - menuRect.width - 10);
+ }
+ }
+
+ if (finalX < 10) finalX = 10;
+ if (finalX + menuRect.width > viewportWidth - 10) {
+ finalX = viewportWidth - menuRect.width - 10;
+ }
+ if (y + menuRect.height > viewportHeight) {
+ finalY = Math.max(10, y - menuRect.height);
+ }
+ if (finalY + menuRect.height > viewportHeight - 10) {
+ finalY = viewportHeight - menuRect.height - 10;
+ }
+ if (finalY < 10) finalY = 10;
+
+ menu.style.left = `${finalX}px`;
+ menu.style.top = `${finalY}px`;
+ menu.style.visibility = 'visible';
+ }
+
+ // Make renderQueue available globally for other modules
+ window.renderQueueFunction = renderQueue;
+
+ // Search tabs
+ document.querySelectorAll('.search-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
+
+ tab.classList.add('active');
+ document.getElementById(`search-tab-${tab.dataset.tab}`).classList.add('active');
+ });
+ });
+}
\ No newline at end of file
diff --git a/js/ui.js b/js/ui.js
index 39f74ba..d6eb8eb 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -1,5 +1,4 @@
-//ui.js
-import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle } from './utils.js';
+import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
import { recentActivityManager } from './storage.js';
export class UIRenderer {
@@ -12,47 +11,48 @@ export class UIRenderer {
}
createTrackMenuButton() {
- return `
-
- `;
-}
- createTrackItemHTML(track, index, showCover = false) {
- const playIconSmall = '
';
- const trackNumberHTML = `
${showCover ? playIconSmall : index + 1}
`;
- const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
- const trackArtists = getTrackArtists(track);
- const trackTitle = getTrackTitle(track);
-
- return `
-
- ${trackNumberHTML}
-
- ${showCover ? `
` : ''}
-
-
- ${trackTitle}
- ${explicitBadge}
-
-
${trackArtists}
-
-
-
${formatTime(track.duration)}
-
- `;
-}
+ `;
+ }
+
+ createTrackItemHTML(track, index, showCover = false) {
+ const playIconSmall = '
';
+ const trackNumberHTML = `
${showCover ? playIconSmall : index + 1}
`;
+ const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
+ const trackArtists = getTrackArtists(track);
+ const trackTitle = getTrackTitle(track);
+
+ return `
+
+ ${trackNumberHTML}
+
+ ${showCover ? `
` : ''}
+
+
+ ${trackTitle}
+ ${explicitBadge}
+
+
${trackArtists}
+
+
+
${formatTime(track.duration)}
+
+
+ `;
+ }
createAlbumCardHTML(album) {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
@@ -150,71 +150,21 @@ export class UIRenderer {
}
}
-async renderHomePage() {
- this.showPage('home');
- const recents = recentActivityManager.getRecents();
-
- const albumsContainer = document.getElementById('home-recent-albums');
- const artistsContainer = document.getElementById('home-recent-artists');
-
- if (recents.albums.length > 0 || recents.artists.length > 0) {
+ async renderHomePage() {
+ this.showPage('home');
+ const recents = recentActivityManager.getRecents();
+
+ const albumsContainer = document.getElementById('home-recent-albums');
+ const artistsContainer = document.getElementById('home-recent-artists');
+
albumsContainer.innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
- : createPlaceholder("You haven't viewed any albums yet.");
+ : createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
artistsContainer.innerHTML = recents.artists.length
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
- : createPlaceholder("You haven't viewed any artists yet.");
- } else {
- // Load from API
- albumsContainer.innerHTML = this.createSkeletonCards(6, false);
- artistsContainer.innerHTML = this.createSkeletonCards(6, true);
-
- const homeData = await window.loadHomeFeed(this.api, this);
-
- if (homeData && homeData.rows) {
- let albums = [];
- let playlists = [];
-
- homeData.rows.forEach(row => {
- row.modules?.forEach(module => {
- if (module.type === 'ALBUM_LIST' && module.pagedList?.items) {
- albums.push(...module.pagedList.items);
- } else if (module.type === 'PLAYLIST_LIST' && module.pagedList?.items) {
- playlists.push(...module.pagedList.items);
- }
- });
- });
-
- if (albums.length > 0) {
- albumsContainer.innerHTML = albums.slice(0, 10).map(album =>
- this.createAlbumCardHTML(album)
- ).join('');
- } else {
- albumsContainer.innerHTML = createPlaceholder("No albums available.");
- }
-
- if (playlists.length > 0) {
- document.querySelector('#home-recent-artists').parentElement.querySelector('.section-title').textContent = 'Featured Playlists';
- artistsContainer.innerHTML = playlists.slice(0, 10).map(playlist => `
-
-
-
-
- ${playlist.title}
- ${playlist.numberOfTracks} tracks
-
- `).join('');
- } else {
- artistsContainer.innerHTML = createPlaceholder("No playlists available.");
- }
- } else {
- albumsContainer.innerHTML = createPlaceholder("Unable to load content.");
- artistsContainer.innerHTML = createPlaceholder("Unable to load content.");
- }
+ : createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
}
-}
async renderSearchPage(query) {
this.showPage('search');
@@ -319,8 +269,18 @@ async renderHomePage() {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
titleEl.innerHTML = `${album.title} ${explicitBadge}`;
+ // Calculate total duration
+ const totalDuration = calculateTotalDuration(tracks);
+ const releaseDate = new Date(album.releaseDate);
+ const year = releaseDate.getFullYear();
+
+ // Desktop: full date, Mobile: year only
+ const dateDisplay = window.innerWidth > 768
+ ? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
+ : year;
+
metaEl.innerHTML =
- `By
${album.artist.name} • ${new Date(album.releaseDate).getFullYear()}`;
+ `By
${album.artist.name} • ${dateDisplay} • ${tracks.length} tracks • ${formatDuration(totalDuration)}`;
tracklistContainer.innerHTML = `