import { LosslessAPI } from './api.js'; import { apiSettings } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore, buildTrackFilename, RATE_LIMIT_ERROR_MESSAGE, debounce, sanitizeForFilename } 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 createDownloadNotification() { if (!downloadNotificationContainer) { downloadNotificationContainer = document.createElement('div'); downloadNotificationContainer.id = 'download-notifications'; downloadNotificationContainer.style.cssText = ` position: fixed; bottom: 120px; right: 20px; z-index: 9999; max-width: 350px; display: flex; flex-direction: column; gap: 0.5rem; `; 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; taskEl.style.cssText = ` background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); animation: slideIn 0.3s ease; `; taskEl.innerHTML = `
${track.title}
${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 }; } 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}%)`; } else if (progress.stage === 'metadata') { const percent = Math.round(progress.progress * 100); progressFill.style.width = `${percent}%`; progressFill.style.background = '#a855f7'; statusEl.textContent = `Embedding metadata: ${percent}%`; } } 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, coverUrl = null) { console.log('[Download] Starting download for:', track.title, 'Quality:', quality); console.log('[Download] Cover URL:', coverUrl); 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'); } } console.log('[Download] Fetching from:', streamUrl); const response = await fetch(streamUrl); if (!response.ok) { throw new Error(`Failed to fetch track: ${response.status}`); } let blob = await response.blob(); console.log('[Download] Downloaded blob size:', blob.size, 'type:', blob.type); if (quality === 'LOSSLESS' && coverUrl) { console.log('[Download] Attempting to embed metadata...'); try { const processedBlob = await api.metadataEmbedder.embedMetadata(blob, track, coverUrl, null); console.log('[Download] Metadata embedded. New size:', processedBlob.size); blob = processedBlob; } catch (error) { console.error('[Download] Metadata embedding failed:', error); } } else { console.log('[Download] Skipping metadata - Quality:', quality, 'Has cover:', !!coverUrl); } 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 coverUrl = album.cover ? api.getCoverUrl(album.cover, '1280') : null; 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); updateBulkDownloadProgress(notification, i, tracks.length, track.title); const blob = await downloadTrackBlob(track, quality, api, coverUrl); 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}`; const coverUrl = fullAlbum.cover ? api.getCoverUrl(fullAlbum.cover, '1280') : null; for (const track of tracks) { const filename = buildTrackFilename(track, quality); const blob = await downloadTrackBlob(track, quality, api, coverUrl); 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.style.cssText = ` background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); animation: slideIn 0.3s ease; `; 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); } } const style = document.createElement('style'); style.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .animate-spin { animation: spin 1s linear infinite; } .download-cancel:hover { background: var(--secondary) !important; color: var(--foreground) !important; } .now-playing-bar .title, .now-playing-bar .artist { cursor: pointer; transition: color 0.2s; } .now-playing-bar .title:hover, .now-playing-bar .artist:hover { color: var(--highlight); text-decoration: underline; } `; document.head.appendChild(style); document.addEventListener('DOMContentLoaded', () => { const api = new LosslessAPI(apiSettings); const ui = new UIRenderer(api); const audioPlayer = document.getElementById('audio-player'); const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS'; const player = new Player(audioPlayer, api, currentQuality); 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; 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); }); } 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}`; } }); document.addEventListener('click', async (e) => { if (e.target.closest('#download-album-btn')) { const btn = e.target.closest('#download-album-btn'); if (btn.disabled) return; const albumId = window.location.hash.split('/')[1]; if (!albumId) return; btn.disabled = true; const originalHTML = btn.innerHTML; btn.innerHTML = 'Downloading...'; try { const { album, tracks } = await api.getAlbum(albumId); await downloadAlbumAsZip(album, tracks, api, player.quality); } catch (error) { console.error('Album download failed:', error); alert('Failed to download album: ' + error.message); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } if (e.target.closest('#download-discography-btn')) { const btn = e.target.closest('#download-discography-btn'); if (btn.disabled) return; const artistId = window.location.hash.split('/')[1]; if (!artistId) return; btn.disabled = true; const originalHTML = btn.innerHTML; btn.innerHTML = 'Downloading...'; try { const artist = await api.getArtist(artistId); await downloadDiscography(artist, api, player.quality); } catch (error) { console.error('Discography download failed:', error); alert('Failed to download discography: ' + error.message); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } }); 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 = () => { if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") { return; } 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 && track.id === (currentQueue[player.currentQueueIndex] || {}).id; return `
${index + 1}
${track.title}
${track.artist?.name || 'Unknown'}
${formatTime(track.duration)}
`; }).join(''); queueList.innerHTML = html; queueList.querySelectorAll('.track-item').forEach((item, index) => { item.addEventListener('click', () => { player.playAtIndex(index); player.updatePlayingTrackIndicator(); renderQueue(); }); }); player.updatePlayingTrackIndicator(); }; mainContent.addEventListener('click', e => { 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) { 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 ); const coverUrl = contextTrack.album?.cover ? api.getCoverUrl(contextTrack.album.cover, '1280') : null; await api.downloadTrack(contextTrack.id, quality, filename, { signal: abortController.signal, track: contextTrack, coverUrl: coverUrl, embedMetadata: true, 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'; }); 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(); if (query) { window.location.hash = `#search/${encodeURIComponent(query)}`; } }); audioPlayer.addEventListener('play', () => { 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; 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', () => { isSeeking = true; wasPlaying = !audioPlayer.paused; if (wasPlaying) audioPlayer.pause(); }); 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; } }); progressBar.addEventListener('click', e => { if (!isSeeking) { seek(progressBar, progressFill, e, position => { if (!isNaN(audioPlayer.duration)) { audioPlayer.currentTime = position * audioPlayer.duration; player.updateMediaSessionPositionState(); } }); } }); volumeBar.addEventListener('click', e => { seek(volumeBar, volumeFill, e, position => { audioPlayer.volume = position; }); }); const updateVolumeUI = () => { const { volume, muted } = audioPlayer; volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME; volumeFill.style.width = `${muted ? 0 : volume * 100}%`; }; 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', e => { const button = e.target.closest('button'); if (!button) return; const li = button.closest('li'); const index = parseInt(li.dataset.index, 10); const instances = 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]]; } else if (button.classList.contains('delete-instance')) { instances.splice(index, 1); } apiSettings.saveInstances(instances); ui.renderApiSettings(); }); document.getElementById('add-instance-form').addEventListener('submit', e => { e.preventDefault(); const input = document.getElementById('custom-instance-input'); const newUrl = input.value.trim(); if (newUrl) { try { const url = new URL(newUrl); if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error('Invalid protocol'); } const instances = apiSettings.getInstances(); const formattedUrl = newUrl.endsWith('/') ? newUrl.slice(0, -1) : newUrl; if (!instances.includes(formattedUrl)) { instances.push(formattedUrl); apiSettings.saveInstances(instances); ui.renderApiSettings(); input.value = ''; } else { alert('This instance is already in the list.'); } } catch (error) { alert('Please enter a valid URL (e.g., https://example.com)'); } } }); 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(); router(); window.addEventListener('hashchange', router); if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('./sw.js') .then(reg => console.log('Service worker registered')) .catch(err => console.log('Service worker not registered', err)); }); } let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; }); });