diff --git a/index.html b/index.html index aa8a7a4..864d97e 100644 --- a/index.html +++ b/index.html @@ -229,25 +229,25 @@
-
- Last.fm Scrobbling - Connect your Last.fm account to scrobble tracks -
-
- -
-
+
+ Last.fm Scrobbling + Connect your Last.fm account to scrobble tracks +
+
+ +
+ - +
Audio Quality @@ -261,21 +261,25 @@
- Crossfade - Fade between tracks smoothly + Now Playing View Mode + Choose what shows when you click the album art +
+ +
+
+
+ Download Lyrics + Include .lrc files when downloading tracks/albums
-
Gapless Playback @@ -288,13 +292,17 @@
- Normalize Volume - Set the same volume level for all tracks + Filename Template + Customize download filenames. Available: {trackNumber}, {artist}, {title}, {album}
- + +
+
+
+ ZIP Folder Template + Customize album folder names. Available: {albumTitle}, {albumArtist}, {year} +
+
@@ -304,15 +312,15 @@
-
-
- API Instances - Manage and prioritize API instances. Automatically sorted by speed. -
- -
-
    -
    +
    +
    + API Instances + Manage and prioritize API instances. Automatically sorted by speed. +
    + +
    +
      +
      @@ -327,6 +335,7 @@

      Features

      @@ -348,7 +358,7 @@
      @@ -369,10 +379,12 @@ + + + + @@ -386,11 +398,26 @@
      + + - -
      + +
      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]) => ` -
      - - -
      - `).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.

      +
      + + `; + 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.

      +
      +
      + + +
      + `; + 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 = ` +
      +
      +

      Keyboard Shortcuts

      + +
      +
      +
      + 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 = ` +
      +

      Lyrics

      +
      + + +
      +
      +
      +
      Loading lyrics...
      +
      + `; + 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]) => ` +
      + + +
      + `).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 ? `Track Cover` : ''} -
      -
      - ${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 ? `Track Cover` : ''} +
      +
      + ${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.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 = `
      @@ -334,6 +294,9 @@ async renderHomePage() { this.renderListWithTracks(tracklistContainer, tracks, false); recentActivityManager.addAlbum(album); + + // Update tab title when no song is playing + document.title = `${album.title} - ${album.artist.name} - Monochrome`; } catch (error) { console.error("Failed to load album:", error); tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`); @@ -370,6 +333,9 @@ async renderHomePage() { ).join(''); recentActivityManager.addArtist(artist); + + // Update tab title + document.title = `${artist.name} - Monochrome`; } catch (error) { console.error("Failed to load artist:", error); tracksContainer.innerHTML = albumsContainer.innerHTML = @@ -378,46 +344,46 @@ async renderHomePage() { } renderApiSettings() { - const container = document.getElementById('api-instance-list'); - this.api.settings.getInstances().then(instances => { - const cachedData = this.api.settings.getCachedSpeedTests(); - const speeds = cachedData?.speeds || {}; - - container.innerHTML = instances.map((url, index) => { - const speedInfo = speeds[url]; - const speedText = speedInfo - ? (speedInfo.speed === Infinity - ? `Failed` - : `${speedInfo.speed.toFixed(0)}ms`) - : ''; + const container = document.getElementById('api-instance-list'); + this.api.settings.getInstances().then(instances => { + const cachedData = this.api.settings.getCachedSpeedTests(); + const speeds = cachedData?.speeds || {}; - return ` -
    • -
      -
      ${url}
      - ${speedText} -
      -
      - - -
      -
    • - `; - }).join(''); + container.innerHTML = instances.map((url, index) => { + const speedInfo = speeds[url]; + const speedText = speedInfo + ? (speedInfo.speed === Infinity + ? `Failed` + : `${speedInfo.speed.toFixed(0)}ms`) + : ''; + + return ` +
    • +
      +
      ${url}
      + ${speedText} +
      +
      + + +
      +
    • + `; + }).join(''); - const stats = this.api.getCacheStats(); - const cacheInfo = document.getElementById('cache-info'); - if (cacheInfo) { - cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`; - } - }); -} + const stats = this.api.getCacheStats(); + const cacheInfo = document.getElementById('cache-info'); + if (cacheInfo) { + cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`; + } + }); + } } \ No newline at end of file diff --git a/js/utils.js b/js/utils.js index f1faab8..97c3826 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,4 +1,5 @@ -//utils.js +// utils.js + export const QUALITY = 'LOSSLESS'; export const REPEAT_MODE = { @@ -62,17 +63,17 @@ export const getExtensionForQuality = (quality) => { }; export const buildTrackFilename = (track, quality) => { + const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}'; const extension = getExtensionForQuality(quality); - const trackNumber = Number(track.trackNumber); - const padded = Number.isFinite(trackNumber) && trackNumber > 0 - ? `${trackNumber}`.padStart(2, '0') - : '00'; - const artistName = sanitizeForFilename(track.artist?.name); - const albumTitle = sanitizeForFilename(track.album?.title); - const trackTitle = sanitizeForFilename(getTrackTitle(track)); + const data = { + trackNumber: track.trackNumber, + artist: track.artist?.name, + title: getTrackTitle(track), + album: track.album?.title + }; - return `${artistName} - ${albumTitle} - ${padded} ${trackTitle}.${extension}`; + return formatTemplate(template, data) + '.' + extension; }; const sanitizeToken = (value) => { @@ -156,15 +157,45 @@ export const debounce = (func, wait) => { timeout = setTimeout(later, wait); }; }; + export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => { - if (!track?.title) return fallback; - return track?.version ? `${track.title} (${track.version})` : track.title; + if (!track?.title) return fallback; + return track?.version ? `${track.title} (${track.version})` : track.title; }; export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}) => { - if (track?.artists?.length) { - return track.artists.map(artist => artist?.name).join(', '); - } + if (track?.artists?.length) { + return track.artists.map(artist => artist?.name).join(', '); + } - return fallback; -} \ No newline at end of file + return fallback; +}; + +export const formatTemplate = (template, data) => { + let result = template; + result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00'); + result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist')); + result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title')); + result = result.replace(/\{album\}/g, sanitizeForFilename(data.album || 'Unknown Album')); + result = result.replace(/\{albumArtist\}/g, sanitizeForFilename(data.albumArtist || 'Unknown Artist')); + result = result.replace(/\{albumTitle\}/g, sanitizeForFilename(data.albumTitle || 'Unknown Album')); + result = result.replace(/\{year\}/g, data.year || 'Unknown'); + return result; +}; + +export const calculateTotalDuration = (tracks) => { + if (!Array.isArray(tracks) || tracks.length === 0) return 0; + return tracks.reduce((total, track) => total + (track.duration || 0), 0); +}; + +export const formatDuration = (seconds) => { + if (!seconds || isNaN(seconds)) return '0 min'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours} hr ${minutes} min`; + } + return `${minutes} min`; +}; diff --git a/styles.css b/styles.css index 91944dd..c4f5ef6 100644 --- a/styles.css +++ b/styles.css @@ -28,6 +28,7 @@ --input: #27272a; --ring: #fafafa; --highlight: #fff; + --highlight-rgb: 255, 255, 255; --active-highlight: var(--highlight); --explicit-badge: #fafafa; } @@ -47,6 +48,7 @@ --input: #2a2a2a; --ring: #3b82f6; --highlight: #3b82f6; + --highlight-rgb: 59, 130, 246; --active-highlight: #3b82f6; --explicit-badge: #ef4444; } @@ -66,6 +68,7 @@ --input: #1e3a52; --ring: #06b6d4; --highlight: #06b6d4; + --highlight-rgb: 6, 182, 212; --active-highlight: #06b6d4; --explicit-badge: #f43f5e; } @@ -85,6 +88,7 @@ --input: #2d1545; --ring: #a855f7; --highlight: #a855f7; + --highlight-rgb: 168, 85, 247; --active-highlight: #a855f7; --explicit-badge: #ec4899; } @@ -104,6 +108,7 @@ --input: #2d4a2d; --ring: #22c55e; --highlight: #22c55e; + --highlight-rgb: 34, 197, 94; --active-highlight: #22c55e; --explicit-badge: #f59e0b; } @@ -144,6 +149,16 @@ a { text-decoration: none; } +kbd { + background-color: var(--secondary); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.85rem; + font-family: 'Courier New', monospace; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + .app-container { display: grid; height: 100vh; @@ -524,7 +539,7 @@ a { } .track-item.playing { - background-color: rgba(var(--highlight-rgb, 255, 255, 255), 0.15); + background-color: rgba(var(--highlight-rgb), 0.15); border-left: 3px solid var(--highlight); padding-left: calc(var(--spacing-sm) - 3px); } @@ -656,11 +671,26 @@ a { display: flex; align-items: center; gap: 1rem; + flex-wrap: wrap; } .detail-header-info .meta { color: var(--muted-foreground); margin-top: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.detail-header-info .meta a { + color: var(--foreground); + text-decoration: none; + transition: color var(--transition); +} + +.detail-header-info .meta a:hover { + color: var(--highlight); } .detail-header-actions { @@ -771,6 +801,23 @@ a { width: 100px; } +.template-input { + width: 100%; + max-width: 400px; + padding: 0.5rem; + background-color: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.9rem; + font-family: 'Courier New', monospace; +} + +.template-input:focus { + outline: none; + border-color: var(--ring); +} + .toggle-switch { position: relative; display: inline-block; @@ -930,38 +977,53 @@ input:checked + .slider::before { color: var(--muted-foreground); } +.progress-bar, +.volume-bar { + position: relative; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + touch-action: none; +} + .progress-bar { flex-grow: 1; - height: 4px; + height: 6px; background-color: var(--secondary); - border-radius: 2px; - cursor: pointer; - position: relative; + border-radius: 3px; + transition: height 0.2s ease; +} + +.progress-bar:hover { + height: 8px; } .progress-bar .progress-fill { width: 0; height: 100%; background-color: var(--foreground); - border-radius: 2px; - transition: width 0.1s linear; + border-radius: 3px; + transition: background-color 0.2s ease; position: relative; + pointer-events: none; } .progress-bar:hover .progress-fill { background-color: var(--highlight); } -.progress-bar:hover .progress-fill::after { +.progress-bar:hover .progress-fill::after, +.progress-bar:active .progress-fill::after { content: ''; position: absolute; - right: 0; + right: -6px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; background-color: var(--highlight); border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } .volume-controls { @@ -976,9 +1038,12 @@ input:checked + .slider::before { border: none; color: var(--muted-foreground); cursor: pointer; - transition: color var(--transition); + transition: all var(--transition); padding: 0.5rem; border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; } .volume-controls button:hover { @@ -991,8 +1056,11 @@ input:checked + .slider::before { height: 4px; background-color: var(--secondary); border-radius: 2px; - cursor: pointer; - position: relative; + transition: height 0.2s ease; +} + +.volume-controls .volume-bar:hover { + height: 6px; } .volume-controls .volume-bar .volume-fill { @@ -1000,22 +1068,27 @@ input:checked + .slider::before { height: 100%; background-color: var(--foreground); border-radius: 2px; + transition: background-color 0.2s ease; + position: relative; + pointer-events: none; } .volume-controls .volume-bar:hover .volume-fill { background-color: var(--highlight); } -.volume-controls .volume-bar:hover .volume-fill::after { +.volume-controls .volume-bar:hover .volume-fill::after, +.volume-controls .volume-bar:active .volume-fill::after { content: ''; position: absolute; - left: calc(var(--volume-level, 70%) - 6px); + right: -6px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; background-color: var(--highlight); border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } #context-menu, @@ -1138,7 +1211,7 @@ input:checked + .slider::before { } .queue-track-item.playing { - background-color: rgba(var(--highlight-rgb, 255, 255, 255), 0.15); + background-color: rgba(var(--highlight-rgb), 0.15); border-left: 3px solid var(--highlight); padding-left: calc(var(--spacing-sm) - 3px); } @@ -1547,6 +1620,179 @@ input:checked + .slider::before { gap: 0.5rem; } +.desktop-only { + display: flex; +} + +#cast-btn { + position: relative; +} + +#cast-btn.available { + color: var(--highlight); +} + +#cast-btn.available::before { + content: ''; + position: absolute; + top: 6px; + right: 6px; + width: 6px; + height: 6px; + background-color: var(--highlight); + border-radius: 50%; + animation: pulse 2s infinite; +} + +#cast-btn.connected { + color: #10b981; +} + +#cast-btn.connected::after { + content: ''; + position: absolute; + top: 6px; + right: 6px; + width: 8px; + height: 8px; + background-color: #10b981; + border-radius: 50%; +} + +#download-current-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.template-guide { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + font-size: 0.9rem; +} + +.template-guide th, +.template-guide td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.template-guide th { + font-weight: 600; + background-color: var(--secondary); +} + +.template-guide code { + background-color: var(--secondary); + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.85em; +} + +.offline-notification, +.update-notification, +.install-prompt { + position: fixed; + bottom: 130px; + right: 20px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + box-shadow: var(--shadow-lg); + z-index: 10000; + display: flex; + align-items: center; + gap: 1rem; + max-width: 350px; + animation: slideIn 0.3s ease; +} + +.offline-notification svg { + flex-shrink: 0; + color: #f59e0b; +} + +.update-notification, +.install-prompt { + flex-direction: column; + align-items: stretch; +} + +.shortcuts-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 10000; + display: flex; + justify-content: center; + align-items: center; + animation: fadeIn 0.2s ease; +} + +.shortcuts-modal { + background: var(--card); + border-radius: var(--radius); + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + box-shadow: var(--shadow-xl); + animation: scaleIn 0.2s ease; +} + +.shortcuts-header { + padding: 1rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.shortcuts-header h3 { + margin: 0; +} + +.close-shortcuts { + background: transparent; + border: none; + color: var(--muted-foreground); + font-size: 1.5rem; + cursor: pointer; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + transition: all var(--transition); +} + +.close-shortcuts:hover { + background: var(--secondary); + color: var(--foreground); +} + +.shortcuts-content { + padding: 1rem; +} + +.shortcut-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.shortcut-item:last-child { + border-bottom: none; +} + +/* Responsive Design */ @media (max-width: 1024px) { .app-container { grid-template-columns: 240px 1fr; @@ -1644,6 +1890,11 @@ input:checked + .slider::before { line-height: 1.2; } + .detail-header-info .meta { + font-size: 0.85rem; + gap: 0.35rem; + } + .detail-header-actions, .btn-primary { width: 100%; @@ -1651,17 +1902,20 @@ input:checked + .slider::before { .now-playing-bar { grid-template: - "track" auto - "controls" auto / 1fr; - gap: var(--spacing-md); - padding: var(--spacing-md); + "track controls" auto + "progress progress" auto / 1fr auto; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); height: auto; } - .now-playing-bar .track-info { grid-area: track; - width: 100%; + min-width: 0; + } + + .track-info { + gap: var(--spacing-sm); } .track-info .cover { @@ -1670,20 +1924,79 @@ input:checked + .slider::before { } .track-info .details { - max-width: calc(100% - 64px); + min-width: 0; + flex: 1; + } + + .track-info .details .title { + font-size: 0.9rem; + } + + .track-info .details .artist { + font-size: 0.75rem; + } + + .now-playing-bar .volume-controls { + grid-area: controls; + display: flex; + gap: 0.25rem; + justify-content: flex-end; } .now-playing-bar .player-controls { - grid-area: controls; + grid-area: progress; width: 100%; } + .player-controls .buttons { + gap: var(--spacing-sm); + margin-bottom: var(--spacing-xs); + } + + .player-controls .buttons button { + width: 28px; + height: 28px; + } + + .player-controls .buttons .play-pause-btn { + width: 32px; + height: 32px; + } + .player-controls .progress-container { max-width: none; + font-size: 0.75rem; + gap: 0.5rem; } - .now-playing-bar .volume-controls { - display: none; + .desktop-only { + display: none !important; + } + + .volume-controls button:not(.desktop-only) { + display: flex; + } + + .volume-controls button { + padding: 0.375rem; + min-width: 32px; + min-height: 32px; + } + + .volume-controls button svg { + width: 18px; + height: 18px; + } + + #download-notifications { + bottom: 10px; + right: 10px; + left: 10px; + max-width: none; + } + + .track-menu-btn { + opacity: 1; } .about-links { @@ -1704,16 +2017,98 @@ input:checked + .slider::before { .setting-item .info { width: 100%; } - - #download-notifications { - bottom: 10px; - right: 10px; - left: 10px; + + .template-input { max-width: none; + font-size: 0.85rem; + } + + .track-item { + grid-template-columns: 28px 1fr 45px 32px; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + } + + .track-number { + font-size: 0.8rem; + width: 28px; + } + + .track-item-info { + gap: var(--spacing-sm); + min-width: 0; + overflow: hidden; + } + + .track-item-cover { + width: 36px; + height: 36px; + } + + .track-item-details { + min-width: 0; + overflow: hidden; + } + + .track-item-details .title { + font-size: 0.85rem; + } + + .track-item-details .artist { + font-size: 0.75rem; + } + + .track-item-duration { + font-size: 0.75rem; + text-align: right; + white-space: nowrap; } .track-menu-btn { - opacity: 1; + padding: 0.5rem; + margin: 0; + } + + .track-menu-btn svg { + width: 18px; + height: 18px; + } + + .queue-track-item { + grid-template-columns: 24px 1fr 40px 28px; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + } + + .queue-track-item .drag-handle { + width: 24px; + } + + .queue-track-item .drag-handle svg { + width: 14px; + height: 14px; + } + + .queue-track-item .track-item-cover { + width: 36px; + height: 36px; + } + + .queue-track-item .track-menu-btn { + padding: 0.5rem; + } + + .sidebar-nav .nav-item a { + padding: 1rem 0.75rem; + } + + .offline-notification, + .update-notification, + .install-prompt { + left: 10px; + right: 10px; + max-width: none; + bottom: 10px; } } @@ -1735,4 +2130,371 @@ input:checked + .slider::before { padding: var(--spacing-sm) var(--spacing-md); font-size: 0.9rem; } + + .player-controls .buttons { + gap: 0.25rem; + } + + .player-controls .buttons button { + width: 24px; + height: 24px; + } + + .player-controls .buttons button svg { + width: 16px; + height: 16px; + } + + .player-controls .buttons .play-pause-btn { + width: 28px; + height: 28px; + } + + .player-controls .buttons .play-pause-btn svg { + width: 18px; + height: 18px; + } + + .volume-controls button { + padding: 0.25rem; + min-width: 28px; + min-height: 28px; + } + + .volume-controls button svg { + width: 16px; + height: 16px; + } + + .track-item { + grid-template-columns: 24px 1fr 40px 28px; + gap: 0.375rem; + padding: 0.5rem; + } + + .track-number { + font-size: 0.75rem; + width: 24px; + } + + .track-item-cover { + width: 32px; + height: 32px; + } + + .track-item-details .title { + font-size: 0.8rem; + } + + .track-item-details .artist { + font-size: 0.7rem; + } + + .track-item-duration { + font-size: 0.7rem; + } + + .track-menu-btn { + padding: 0.25rem; + } + + .track-menu-btn svg { + width: 16px; + height: 16px; + } + + .queue-track-item { + grid-template-columns: 20px 1fr 36px 24px; + gap: 0.375rem; + padding: 0.5rem; + } + + .queue-track-item .drag-handle { + width: 20px; + } + + .queue-track-item .track-item-cover { + width: 32px; + height: 32px; + } +} + +@media (max-width: 360px) { + .player-controls .buttons { + justify-content: space-between; + width: 100%; + } + + #shuffle-btn, + #repeat-btn { + display: none; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .now-playing-bar { + grid-template-columns: 1fr 2fr auto; + padding: var(--spacing-md); + } + + .volume-controls { + display: flex; + } + + .desktop-only { + display: flex; + } +} + +@media (min-width: 1440px) { + .card-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } +} + +@media (min-width: 1920px) { + .card-grid { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + } +} + +@media (hover: none) and (pointer: coarse) { + .progress-bar, + .volume-bar { + height: 8px; + } + + .progress-bar .progress-fill::after, + .volume-bar .volume-fill::after { + content: ''; + position: absolute; + right: -8px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + background-color: var(--highlight); + border-radius: 50%; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + } + + .track-item, + .queue-track-item { + padding: var(--spacing-md) var(--spacing-sm); + } + + button { + min-height: 44px; + min-width: 44px; + } + + .player-controls .buttons button { + min-height: 36px; + min-width: 36px; + } +} + +@supports (padding-top: env(safe-area-inset-top)) { + .main-header { + padding-top: max(var(--spacing-md), env(safe-area-inset-top)); + } + + .now-playing-bar { + padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom)); + } + + .sidebar { + padding-top: max(1.5rem, env(safe-area-inset-top)); + } +} +/* Lyrics Panel */ +.lyrics-panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + width: 400px; + max-width: 90vw; + background: var(--card); + border-left: 1px solid var(--border); + z-index: 3000; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-xl); +} + +.lyrics-panel:not(.hidden) { + transform: translateX(0); +} + +.lyrics-header { + padding: 1rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.lyrics-header h3 { + margin: 0; +} + +.lyrics-controls { + display: flex; + gap: 0.5rem; +} + +.btn-icon { + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 0.5rem; + border-radius: var(--radius); + transition: all var(--transition); + display: flex; + align-items: center; + justify-content: center; +} + +.btn-icon:hover { + background: var(--secondary); + color: var(--foreground); +} + +.lyrics-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.lyrics-line { + margin: 0.75rem 0; + line-height: 1.6; + color: var(--foreground); +} + +.lyrics-loading, +.lyrics-error { + text-align: center; + padding: 2rem; + color: var(--muted-foreground); +} + +/* Karaoke View */ +.karaoke-view { + position: fixed; + inset: 0; + background: var(--background); + z-index: 4000; + display: flex; + flex-direction: column; + animation: fadeIn 0.3s ease; +} + +.karaoke-header { + padding: 1rem; + display: flex; + justify-content: flex-end; +} + +.karaoke-track-info { + text-align: center; + padding: 2rem 1rem; +} + +.karaoke-title { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.karaoke-artist { + font-size: 1.25rem; + color: var(--muted-foreground); +} + +.karaoke-lyrics-container { + flex: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.karaoke-line { + font-size: 1.5rem; + line-height: 1.8; + color: var(--muted-foreground); + transition: all 0.3s ease; + text-align: center; + max-width: 800px; + opacity: 0.4; +} + +.karaoke-line.active { + color: var(--highlight); + font-size: 2rem; + font-weight: 600; + opacity: 1; + transform: scale(1.1); +} + +.karaoke-line.past { + opacity: 0.6; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .lyrics-panel { + width: 100vw; + } + + .karaoke-title { + font-size: 1.5rem; + } + + .karaoke-artist { + font-size: 1rem; + } + + .karaoke-line { + font-size: 1.25rem; + } + + .karaoke-line.active { + font-size: 1.5rem; + } +} + +/* Clickable album cover indicator */ +.now-playing-bar .cover { + cursor: pointer; + transition: all var(--transition); + position: relative; +} + +.now-playing-bar .cover:hover { + transform: scale(1.05); +} + +.now-playing-bar .cover::after { + content: '🎵'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + opacity: 0; + transition: opacity var(--transition); + font-size: 1.5rem; +} + +.now-playing-bar .cover:hover::after { + opacity: 1; } \ No newline at end of file