//js/app.js import discordSvg from '../images/discord.svg?svg&size=22'; import googleSvg from '../images/google.svg?svg&size=22'; import githubSvg from '../images/github.svg?svg&size=22'; import spotifySvg from '../images/spotify.svg?svg&size=22'; import { isIos, isSafari } from './platform-detection.js'; import { hapticLight } from './haptics.js'; import { MusicAPI } from './music-api.js'; import { apiSettings, themeManager, nowPlayingSettings, fullscreenCoverClickSettings, downloadQualitySettings, sidebarSettings, pwaUpdateSettings, modalSettings, keyboardShortcuts, } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; import { MultiScrobbler } from './multi-scrobbler.js'; import { LyricsManager, openLyricsPanel, clearLyricsPanelSync } from './lyrics.js'; import { createRouter, updateTabTitle, navigate } from './router.js'; import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js'; import { initializeUIInteractions } from './ui-interactions.js'; import { debounce, getShareUrl } from './utils.js'; import { sidePanelManager } from './side-panel.js'; import { db } from './db.js'; import { showNotification } from './downloads.js'; import { syncManager } from './accounts/pocketbase.js'; import { authManager } from './accounts/auth.js'; import { registerSW } from 'virtual:pwa-register'; import { openEditProfile } from './profile.js'; import { ThemeStore } from './themeStore.js'; import './commandPalette.js'; import { initTracker } from './tracker.js'; import { initAnalytics, trackSidebarNavigation, trackCreatePlaylist, trackCreateFolder, trackImportJSPF, trackImportCSV, trackImportXSPF, trackImportXML, trackImportM3U, trackSelectLocalFolder, trackChangeLocalFolder, trackOpenModal, trackCloseModal, trackKeyboardShortcut, trackPwaUpdate, trackDismissUpdate, trackOpenFullscreenCover, trackCloseFullscreenCover, trackOpenLyrics, trackCloseLyrics, } from './analytics.js'; import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U, parseDynamicCSV, importToLibrary, } from './playlist-importer.js'; import { modernSettings } from './ModernSettings.js'; import { SVG_OFFLINE, SVG_RIGHT_ARROW, SVG_LEFT_ARROW, SVG_ANIMATE_SPIN, SVG_PLAY, SVG_CLOSE, SVG_RESET, } from './icons.js'; import { HiFiClient } from './HiFi.js'; // Capture real iOS state before spoofing (needed for background audio) if (typeof window !== 'undefined') { const _ua = navigator.userAgent.toLowerCase(); // Spoof User-Agent to bypass Google's embedded browser check Object.defineProperty(navigator, 'userAgent', { get: function () { return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; }, }); // analytics const plausibleScript = document.createElement('script'); plausibleScript.async = true; plausibleScript.src = 'https://plausible.canine.tools/js/pa-dCMvQpiD1-AJmi8o3xviO.js'; document.head.appendChild(plausibleScript); window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); }; window.plausible.init = window.plausible.init || function (i) { window.plausible.o = i || {}; }; window.plausible.init(); } // Lazy-loaded modules let settingsModule = null; let downloadsModule = null; let metadataModule = null; async function loadSettingsModule() { if (!settingsModule) { settingsModule = await import('./settings.js'); } return settingsModule; } async function loadDownloadsModule() { if (!downloadsModule) { downloadsModule = await import('./downloads.js'); } return downloadsModule; } async function loadMetadataModule() { if (!metadataModule) { metadataModule = await import('./metadata.js'); } return metadataModule; } function initializeCasting(audioPlayer, castBtn) { if (!castBtn) return; 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); if (window.innerWidth > 768) { castBtn.style.display = 'flex'; } }); castBtn.addEventListener('click', () => { if (!audioPlayer.src) { alert('Please play a track first to enable casting.'); return; } audioPlayer.remote.prompt().catch((err) => { if (err.name === 'NotAllowedError') return; if (err.name === 'NotFoundError') { alert('No remote playback devices (Chromecast/AirPlay) were found on your network.'); return; } console.log('Cast prompt error:', err); }); }); 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'); } }); } else if (audioPlayer.webkitShowPlaybackTargetPicker) { castBtn.style.display = 'flex'; castBtn.classList.add('available'); castBtn.addEventListener('click', () => { audioPlayer.webkitShowPlaybackTargetPicker(); }); 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'); } }); } 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 initializeKeyboardShortcuts(player, _audioPlayer) { const keyActionMap = { playPause: () => { trackKeyboardShortcut('Space'); player.handlePlayPause(); }, seekForward: () => { trackKeyboardShortcut('Right'); player.seekForward(10); }, seekBackward: () => { trackKeyboardShortcut('Left'); player.seekBackward(10); }, nextTrack: () => { trackKeyboardShortcut('Shift+Right'); player.playNext(); }, previousTrack: () => { trackKeyboardShortcut('Shift+Left'); player.playPrev(); }, volumeUp: () => { trackKeyboardShortcut('Up'); player.setVolume(player.userVolume + 0.1); }, volumeDown: () => { trackKeyboardShortcut('Down'); player.setVolume(player.userVolume - 0.1); }, mute: () => { trackKeyboardShortcut('M'); const el = player.activeElement; el.muted = !el.muted; }, shuffle: () => { trackKeyboardShortcut('S'); document.getElementById('shuffle-btn')?.click(); }, repeat: () => { trackKeyboardShortcut('R'); document.getElementById('repeat-btn')?.click(); }, queue: () => { trackKeyboardShortcut('Q'); document.getElementById('queue-btn')?.click(); }, lyrics: () => { trackKeyboardShortcut('L'); document.querySelector('.now-playing-bar .cover')?.click(); }, search: () => { trackKeyboardShortcut('/'); document.getElementById('search-input')?.focus(); }, escape: () => { trackKeyboardShortcut('Escape'); document.getElementById('search-input')?.blur(); sidePanelManager.close(); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel); }, visualizerNext: () => { trackKeyboardShortcut('VisualizerNext'); if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) { UIRenderer.instance.visualizer.presets['butterchurn'].nextPreset(); } }, visualizerPrev: () => { trackKeyboardShortcut('VisualizerPrev'); if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) { UIRenderer.instance.visualizer.presets['butterchurn'].prevPreset(); } }, visualizerCycle: () => { trackKeyboardShortcut('VisualizerCycle'); if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) { UIRenderer.instance.visualizer.presets['butterchurn'].toggleCycle(); } }, }; document.addEventListener('keydown', (e) => { if (e.target.matches('input, textarea, [contenteditable="true"]')) return; const shortcuts = keyboardShortcuts.getShortcuts(); const pressedKey = e.key.toLowerCase(); const hasShift = e.shiftKey; const hasCtrl = e.ctrlKey || e.metaKey; const hasAlt = e.altKey; for (const [action, shortcut] of Object.entries(shortcuts)) { if (!shortcut?.key) continue; const shortcutKey = shortcut.key.toLowerCase(); const matches = pressedKey === shortcutKey && shortcut.shift === hasShift && shortcut.ctrl === hasCtrl && shortcut.alt === hasAlt; if (matches) { e.preventDefault(); const actionFn = keyActionMap[action]; if (actionFn) { actionFn(); } return; } } }); } function showOfflineNotification() { const notification = document.createElement('div'); notification.className = 'offline-notification'; notification.innerHTML = ` ${SVG_OFFLINE(20)} You are offline. Some features may not work. `; document.body.appendChild(notification); setTimeout(() => { notification.style.animation = 'slide-out 0.3s ease forwards'; setTimeout(() => notification.remove(), 300); }, 5000); } function hideOfflineNotification() { const notification = document.querySelector('.offline-notification'); if (notification) { notification.style.animation = 'slide-out 0.3s ease forwards'; setTimeout(() => notification.remove(), 300); } } async function disablePwaForAuthGate() { if (!('serviceWorker' in navigator)) return; try { const registrations = await navigator.serviceWorker.getRegistrations(); await Promise.all(registrations.map((registration) => registration.unregister())); } catch (error) { console.warn('Failed to unregister service workers:', error); } if ('caches' in window) { try { const cacheKeys = await caches.keys(); await Promise.all(cacheKeys.map((key) => caches.delete(key))); } catch (error) { console.warn('Failed to clear caches:', error); } } } async function uploadCoverImage(file) { try { const response = await fetch(`https://worker.uploads.monochrome.qzz.io/${file.name}`, { method: 'PUT', headers: { 'x-api-key': 'if_youre_reading_this_fuck_off', 'Content-Type': file.type || 'application/octet-stream', }, body: file, }); if (!response.ok) { if (response.status === 413) throw new Error('File exceeds 10MB'); throw new Error(`Upload failed: ${response.status}`); } return `https://images.monochrome.qzz.io/${await response.text()}`; } catch (error) { console.error('Cover upload error:', error); throw error; } } document.addEventListener('DOMContentLoaded', async () => { await modernSettings.waitPending(); if (import.meta.env.DEV) { window.monochrome = { HiFiClient, LyricsManager, MusicAPI, Player, UIRenderer, }; } // Haptic feedback on every click document.addEventListener('click', () => hapticLight(), { capture: true }); // Initialize analytics initAnalytics(); new ThemeStore(); await HiFiClient.initialize({ storage: [ localStorage, ...(import.meta.env.DEV ? [ { setItem: (key, value) => console.debug(`HiFiClient storage set: ${key} = ${value}`), removeItem: (key) => console.debug(`HiFiClient storage remove: ${key}`), }, ] : []), ], token: localStorage.getItem('hifi_token') || undefined, tokenExpiry: parseInt(localStorage.getItem('hifi_token_expiry') || '0'), }); await MusicAPI.initialize(apiSettings); const audioPlayer = document.getElementById('audio-player'); // i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback // Use isIos from platform-detection (set before UA spoof in index.html) so detection works on real iOS. if (isIos || isSafari) { const qualitySelect = document.getElementById('streaming-quality-setting'); const downloadQualitySelect = document.getElementById('download-quality-setting'); const removeHiRes = (select) => { if (!select) return; const option = select.querySelector('option[value="HI_RES_LOSSLESS"]'); if (option) option.remove(); }; removeHiRes(qualitySelect); removeHiRes(downloadQualitySelect); if (isIos) { document.querySelector('#hi-res-download-warning').style.display = ''; } const currentQualitySetting = localStorage.getItem('playback-quality'); if (!currentQualitySetting || currentQualitySetting === 'HI_RES_LOSSLESS') { localStorage.setItem('playback-quality', 'LOSSLESS'); } } const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS'; await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality); // Initialize tracker initTracker(); // Linux Media Keys Fix if (window.NL_MODE) { import('./desktop/neutralino-bridge.js').then(({ events }) => { events.on('mediaNext', () => Player.instance.playNext()); events.on('mediaPrevious', () => Player.instance.playPrev()); events.on('mediaPlayPause', () => Player.instance.handlePlayPause()); events.on('mediaStop', () => { const el = Player.instance.activeElement; el.pause(); el.currentTime = 0; }); console.log('Media keys initialized via bridge'); }); } // Initialize desktop features if in Neutralino mode if ( typeof window !== 'undefined' && (window.NL_MODE || window.location.search.includes('mode=neutralino') || window.location.search.includes('nl_port=')) ) { window.NL_MODE = true; try { const desktopModule = await import('./desktop/desktop.js'); await desktopModule.initDesktop(Player.instance); import('./desktop/neutralino-bridge.js').then(({ updater }) => { setTimeout(async () => { try { // my worker should detect a users OS and serve the right ver const update = await updater.checkForUpdates('https://update.samidy.xyz/update.json'); if (update && update.available) { const modal = document.getElementById('desktop-update-modal'); const notes = document.getElementById('desktop-update-notes'); const confirmBtn = document.getElementById('desktop-update-confirm'); const cancelBtn = document.getElementById('desktop-update-cancel'); if (modal) { notes.innerHTML = update.notes || 'Bug fixes and improvements.'; modal.classList.add('active'); confirmBtn.onclick = async () => { confirmBtn.disabled = true; confirmBtn.textContent = 'Updating...'; try { await updater.install(); } catch (err) { console.error(err); confirmBtn.textContent = 'Failed'; setTimeout(() => { confirmBtn.disabled = false; confirmBtn.textContent = 'Update Now'; }, 2000); } }; cancelBtn.onclick = () => modal.classList.remove('active'); } } } catch (e) { console.warn('Failed to check for desktop updates:', e); } }, 3000); }); } catch (err) { console.error('Failed to load desktop module:', err); } } const castBtn = document.getElementById('cast-btn'); initializeCasting(audioPlayer, castBtn); await UIRenderer.initialize(MusicAPI.instance, Player.instance); /** * Scans the configured local media folder and refreshes `window.localFilesCache`. * Called by the folder-select button handler and by downloads.js after a * successful write to the local media folder. * * @param {boolean} [onlyIfAlreadyScanned=false] When true, skips the scan if * `window.localFilesCache` has never been populated (i.e. the user hasn't * visited the local tab yet). */ async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) { // Skip the scan if the user has never visited the local tab – they'll // get a fresh scan when they navigate there for the first time. if (onlyIfAlreadyScanned && !window.localFilesCache) return; // Prevent concurrent scans. if (window.localFilesScanInProgress) return; window.localFilesScanInProgress = true; try { const handle = await db.getSetting('local_folder_handle'); if (!handle) return; const isNeutralino = window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino')); const tracks = (window.localFilesCache = []); let idCounter = 0; const { readTrackMetadata } = await loadMetadataModule(); if (isNeutralino) { async function scanNeu(dirPath) { const entries = await window.Neutralino.filesystem.readDirectory(dirPath); for (const entry of entries) { if (entry.entry === '.' || entry.entry === '..') continue; const fullPath = `${dirPath}/${entry.entry}`; if (entry.type === 'FILE') { const name = entry.entry.toLowerCase(); if ( name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.m4a') || name.endsWith('.wav') || name.endsWith('.ogg') ) { try { const buffer = await window.Neutralino.filesystem.readBinaryFile(fullPath); const stats = await window.Neutralino.filesystem.getStats(fullPath); const file = new File([buffer], entry.entry, { lastModified: stats.mtime }); const metadata = await readTrackMetadata(file); metadata.id = `local-${idCounter++}-${entry.entry}`; tracks.push(metadata); UIRenderer.instance.renderLocalFiles( document.getElementById('library-local-container') ); } catch (e) { console.error('Failed to read file:', fullPath, e); } } } else if (entry.type === 'DIRECTORY') { await scanNeu(fullPath); } } } await scanNeu(handle.path); } else { // Request read permission before iterating. When the browser has // already granted it (e.g. within the same session or via a // persistent grant) this succeeds without a user gesture. if (typeof handle.requestPermission === 'function') { const permission = await handle.requestPermission({ mode: 'read' }); if (permission !== 'granted') return; } async function scanBrowser(dirHandle) { for await (const entry of dirHandle.values()) { if (entry.kind === 'file') { const name = entry.name.toLowerCase(); if ( name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.m4a') || name.endsWith('.wav') || name.endsWith('.ogg') ) { const file = await entry.getFile(); const metadata = await readTrackMetadata(file); metadata.id = `local-${idCounter++}-${file.name}`; tracks.push(metadata); UIRenderer.instance.renderLocalFiles( document.getElementById('library-local-container') ); } } else if (entry.kind === 'directory') { await scanBrowser(entry); } } } await scanBrowser(handle); } tracks.sort((a, b) => (a.artist.name || '').localeCompare(b.artist.name || '')); // Update only the local-files section without navigating to the library page. UIRenderer.instance.renderLocalFiles(document.getElementById('library-local-container')); } finally { window.localFilesScanInProgress = false; } return window.localFilesCache; } /** * Called by downloads.js (via window) after a successful write to the local * media folder so the track appears in Library > Local without the user * having to manually re-scan. * * When called with a `blob` and `filename` (single-track download case) it * performs a cheap partial update — reading metadata only from that one file * and inserting it into the existing cache — so the full folder does not need * to be re-walked. When called with no arguments (bulk download case, or when * `localFilesCache` has never been populated) it falls back to a full rescan. */ window.refreshLocalMediaFolder = async (blob = null, filename = null) => { if (blob && filename) { try { /** @type {import("./metadata.js")} */ const { readTrackMetadata } = await loadMetadataModule(); const baseName = filename.split('/').pop(); const metadata = await readTrackMetadata(new Uint8Array(await blob.arrayBuffer()), { filename: baseName, }); const existing = window.localFilesCache || []; metadata.id = `local-${existing.length}-${baseName}`; window.localFilesCache = [...existing, metadata].sort((a, b) => (a.artist.name || '').localeCompare(b.artist.name || '') ); UIRenderer.instance.renderLocalFiles(document.getElementById('library-local-container')); } catch { // Fall back to a full rescan if metadata extraction fails. await scanLocalMediaFolder(true); } } else { await scanLocalMediaFolder(!!window.localFilesCache); } }; // Kick off a background scan of the saved local media folder on startup so // that the Library > Local tab is populated without requiring the user to // manually press "Load [folder]" every session. The function internally // checks for a saved handle and (in browser mode) requests read permission, // so this is a silent no-op when no folder is configured or permission is not // yet granted. scanLocalMediaFolder(); const scrobbler = new MultiScrobbler(); window.monochromeScrobbler = scrobbler; const lyricsManager = await LyricsManager.initialize(MusicAPI.instance); UIRenderer.instance.lyricsManager = lyricsManager; // Check browser support for local files const selectLocalBtn = document.getElementById('select-local-folder-btn'); const browserWarning = document.getElementById('local-browser-warning'); if (selectLocalBtn && browserWarning) { const ua = navigator.userAgent; const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua); const hasFileSystemApi = 'showDirectoryPicker' in window; const isNeutralino = window.NL_MODE || window.location.search.includes('mode=neutralino') || window.location.search.includes('nl_port='); if (!isNeutralino && (!isChromeOrEdge || !hasFileSystemApi)) { selectLocalBtn.style.display = 'none'; browserWarning.style.display = 'block'; } else if (isNeutralino) { selectLocalBtn.style.display = 'flex'; browserWarning.style.display = 'none'; } } // Kuroshiro is now loaded on-demand only when needed for Asian text with Romaji mode enabled const currentTheme = themeManager.getTheme(); themeManager.setTheme(currentTheme); // Restore sidebar state sidebarSettings.restoreState(); // Render pinned items await UIRenderer.instance.renderPinnedItems(); // Load settings module and initialize const { initializeSettings } = await loadSettingsModule(); await initializeSettings(scrobbler, Player.instance, MusicAPI.instance, UIRenderer.instance); // Track sidebar navigation clicks document.querySelectorAll('.sidebar-nav a').forEach((link) => { link.addEventListener('click', () => { const href = link.getAttribute('href'); if (href && !href.startsWith('http')) { const item = link.querySelector('span')?.textContent || href; trackSidebarNavigation(item); } }); }); initializePlayerEvents(Player.instance, audioPlayer, scrobbler, UIRenderer.instance); initializeTrackInteractions( Player.instance, MusicAPI.instance, document.querySelector('.main-content'), document.getElementById('context-menu'), lyricsManager, UIRenderer.instance, scrobbler ); initializeUIInteractions(Player.instance, MusicAPI.instance, UIRenderer.instance); initializeKeyboardShortcuts(Player.instance, audioPlayer); // Restore UI state for the current track (like button, theme) if (Player.instance.currentTrack) { UIRenderer.instance.setCurrentTrack(Player.instance.currentTrack); } document.querySelector('.now-playing-bar').addEventListener('click', async (e) => { if (!e.target.closest('.cover')) return; if (!Player.instance.currentTrack) { alert('No track is currently playing'); return; } const mode = nowPlayingSettings.getMode(); if (mode === 'lyrics') { const isActive = sidePanelManager.isActive('lyrics'); if (isActive) { trackCloseLyrics(Player.instance.currentTrack); } else { trackOpenLyrics(Player.instance.currentTrack); } } else if (mode === 'cover') { const overlay = document.getElementById('fullscreen-cover-overlay'); if (overlay && overlay.style.display === 'flex') { trackCloseFullscreenCover(); } else { trackOpenFullscreenCover(Player.instance.currentTrack); } } if (mode === 'lyrics') { const isActive = sidePanelManager.isActive('lyrics'); if (isActive) { sidePanelManager.close(); clearLyricsPanelSync(Player.instance.activeElement, sidePanelManager.panel); } else { openLyricsPanel(Player.instance.currentTrack, Player.instance.activeElement, lyricsManager); } } else if (mode === 'cover') { const overlay = document.getElementById('fullscreen-cover-overlay'); if (overlay && overlay.style.display === 'flex') { if (window.location.hash === '#fullscreen') { window.history.back(); } else { UIRenderer.instance.closeFullscreenCover(); } } else { const nextTrack = Player.instance.getNextTrack(); UIRenderer.instance.showFullscreenCover( Player.instance.currentTrack, nextTrack, lyricsManager, Player.instance.activeElement ); } } else { // Default to 'album' mode - navigate to album if (Player.instance.currentTrack.album?.id) { navigate(`/album/${Player.instance.currentTrack.album.id}`); } } }); // Toggle Share Button visibility on switch change document.getElementById('playlist-public-toggle')?.addEventListener('change', (e) => { const shareBtn = document.getElementById('playlist-share-btn'); if (shareBtn) shareBtn.style.display = e.target.checked ? 'flex' : 'none'; }); document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => { trackCloseFullscreenCover(); if (window.location.hash === '#fullscreen') { window.history.back(); } else { UIRenderer.instance.closeFullscreenCover(); } }); document.getElementById('fullscreen-cover-overlay')?.addEventListener('click', (e) => { const coverImage = document.getElementById('fullscreen-cover-image'); if (!coverImage) return; const isOnCoverImage = e.target.closest('#fullscreen-cover-image') || e.target.id === 'fullscreen-cover-image'; if (!isOnCoverImage) return; const action = fullscreenCoverClickSettings.getAction(); const overlay = document.getElementById('fullscreen-cover-overlay'); const playerInstance = Player.instance; switch (action) { case 'exit': if (window.location.hash === '#fullscreen') { window.history.back(); } else { UIRenderer.instance.closeFullscreenCover(); } break; case 'hide-ui': if (overlay) { const isCurrentlyHidden = overlay.classList.contains('ui-hidden'); if (isCurrentlyHidden) { overlay.classList.remove('ui-hidden'); const toggleBtn = document.getElementById('toggle-ui-btn'); if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.classList.add('visible'); toggleBtn.title = 'Hide UI'; } } else { overlay.classList.add('ui-hidden'); const toggleBtn = document.getElementById('toggle-ui-btn'); if (toggleBtn) { toggleBtn.classList.add('active'); toggleBtn.classList.remove('visible'); toggleBtn.title = 'Show UI'; } } if (UIRenderer.instance && typeof UIRenderer.instance.setupUIToggleButton === 'function') { if (UIRenderer.instance.uiToggleCleanup) { UIRenderer.instance.uiToggleCleanup(); } UIRenderer.instance.setupUIToggleButton(overlay); } } break; case 'pause-resume': if (playerInstance) playerInstance.handlePlayPause(); break; case 'next': if (playerInstance) playerInstance.playNext(); break; case 'previous': if (playerInstance) playerInstance.playPrev(); break; case 'nothing': break; default: if (window.location.hash === '#fullscreen') { window.history.back(); } else { UIRenderer.instance.closeFullscreenCover(); } } }); document.getElementById('sidebar-toggle')?.addEventListener('click', () => { document.body.classList.toggle('sidebar-collapsed'); const isCollapsed = document.body.classList.contains('sidebar-collapsed'); const toggleBtn = document.getElementById('sidebar-toggle'); if (toggleBtn) { toggleBtn.innerHTML = isCollapsed ? SVG_RIGHT_ARROW(20) : SVG_LEFT_ARROW(20); } // Save sidebar state to localStorage sidebarSettings.setCollapsed(isCollapsed); }); // Import tab switching in playlist modal document.querySelectorAll('.import-tab').forEach((tab) => { tab.addEventListener('click', () => { const importType = tab.dataset.importType; // Update tab styles document.querySelectorAll('.import-tab').forEach((t) => { t.classList.remove('active'); t.style.opacity = '0.7'; }); tab.classList.add('active'); tab.style.opacity = '1'; // Show/hide panels document.getElementById('csv-import-panel').style.display = importType === 'csv' ? 'block' : 'none'; document.getElementById('jspf-import-panel').style.display = importType === 'jspf' ? 'block' : 'none'; document.getElementById('xspf-import-panel').style.display = importType === 'xspf' ? 'block' : 'none'; document.getElementById('xml-import-panel').style.display = importType === 'xml' ? 'block' : 'none'; document.getElementById('m3u-import-panel').style.display = importType === 'm3u' ? 'block' : 'none'; // Clear all file inputs except the active one document.getElementById('csv-file-input').value = importType === 'csv' ? document.getElementById('csv-file-input').value : ''; document.getElementById('jspf-file-input').value = importType === 'jspf' ? document.getElementById('jspf-file-input').value : ''; document.getElementById('xspf-file-input').value = importType === 'xspf' ? document.getElementById('xspf-file-input').value : ''; document.getElementById('xml-file-input').value = importType === 'xml' ? document.getElementById('xml-file-input').value : ''; document.getElementById('m3u-file-input').value = importType === 'm3u' ? document.getElementById('m3u-file-input').value : ''; }); }); const spotifyBtn = document.getElementById('csv-spotify-btn'); const appleBtn = document.getElementById('csv-apple-btn'); const ytmBtn = document.getElementById('csv-ytm-btn'); const spotifyGuide = document.getElementById('csv-spotify-guide'); const appleGuide = document.getElementById('csv-apple-guide'); const ytmGuide = document.getElementById('csv-ytm-guide'); const inputContainer = document.getElementById('csv-input-container'); if (spotifyBtn && appleBtn && ytmBtn) { spotifyBtn.addEventListener('click', () => { spotifyBtn.classList.remove('btn-secondary'); spotifyBtn.classList.add('btn-primary'); spotifyBtn.style.opacity = '1'; appleBtn.classList.remove('btn-primary'); appleBtn.classList.add('btn-secondary'); appleBtn.style.opacity = '0.7'; ytmBtn.classList.remove('btn-primary'); ytmBtn.classList.add('btn-secondary'); ytmBtn.style.opacity = '0.7'; spotifyGuide.style.display = 'block'; appleGuide.style.display = 'none'; ytmGuide.style.display = 'none'; inputContainer.style.display = 'block'; }); appleBtn.addEventListener('click', () => { appleBtn.classList.remove('btn-secondary'); appleBtn.classList.add('btn-primary'); appleBtn.style.opacity = '1'; spotifyBtn.classList.remove('btn-primary'); spotifyBtn.classList.add('btn-secondary'); spotifyBtn.style.opacity = '0.7'; ytmBtn.classList.remove('btn-primary'); ytmBtn.classList.add('btn-secondary'); ytmBtn.style.opacity = '0.7'; appleGuide.style.display = 'block'; spotifyGuide.style.display = 'none'; ytmGuide.style.display = 'none'; inputContainer.style.display = 'block'; }); ytmBtn.addEventListener('click', () => { ytmBtn.classList.remove('btn-secondary'); ytmBtn.classList.add('btn-primary'); ytmBtn.style.opacity = '1'; spotifyBtn.classList.remove('btn-primary'); spotifyBtn.classList.add('btn-secondary'); spotifyBtn.style.opacity = '0.7'; appleBtn.classList.remove('btn-primary'); appleBtn.classList.add('btn-secondary'); appleBtn.style.opacity = '0.7'; ytmGuide.style.display = 'block'; spotifyGuide.style.display = 'none'; appleGuide.style.display = 'none'; inputContainer.style.display = 'none'; }); } // Cover image upload functionality const coverUploadBtn = document.getElementById('playlist-cover-upload-btn'); const coverFileInput = document.getElementById('playlist-cover-file-input'); const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn'); const coverUrlInput = document.getElementById('playlist-cover-input'); const coverUploadStatus = document.getElementById('playlist-cover-upload-status'); const coverUploadText = document.getElementById('playlist-cover-upload-text'); let useUrlInput = false; coverUploadBtn?.addEventListener('click', () => { if (useUrlInput) return; coverFileInput?.click(); }); coverFileInput?.addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; // Validate file type if (!file.type.startsWith('image/')) { alert('Please select an image file'); return; } // Show uploading status coverUploadStatus.style.display = 'block'; coverUploadText.textContent = 'Uploading...'; coverUploadBtn.disabled = true; try { const publicUrl = await uploadCoverImage(file); coverUrlInput.value = publicUrl; coverUploadText.textContent = 'Done!'; coverUploadText.style.color = 'var(--success)'; setTimeout(() => { coverUploadStatus.style.display = 'none'; }, 2000); } catch (error) { coverUploadText.textContent = 'Failed - try URL'; coverUploadText.style.color = 'var(--error)'; console.error('Upload failed:', error); } finally { coverUploadBtn.disabled = false; } }); coverToggleUrlBtn?.addEventListener('click', () => { useUrlInput = !useUrlInput; if (useUrlInput) { coverUploadBtn.style.flex = '0 0 auto'; coverUploadBtn.style.display = 'none'; coverUrlInput.style.display = 'block'; coverToggleUrlBtn.textContent = 'Upload'; coverToggleUrlBtn.title = 'Switch to file upload'; } else { coverUploadBtn.style.flex = '1'; coverUploadBtn.style.display = 'flex'; coverUrlInput.style.display = 'none'; coverToggleUrlBtn.textContent = 'or URL'; coverToggleUrlBtn.title = 'Switch to URL input'; } }); document.getElementById('nav-back')?.addEventListener('click', () => { window.history.back(); }); document.getElementById('nav-forward')?.addEventListener('click', () => { window.history.forward(); }); document.getElementById('toggle-lyrics-btn')?.addEventListener('click', async (e) => { e.stopPropagation(); if (!Player.instance.currentTrack) { alert('No track is currently playing'); return; } const isActive = sidePanelManager.isActive('lyrics'); if (isActive) { sidePanelManager.close(); clearLyricsPanelSync(Player.instance.activeElement, sidePanelManager.panel); } else { openLyricsPanel(Player.instance.currentTrack, Player.instance.activeElement, lyricsManager); } }); document.getElementById('download-current-btn')?.addEventListener('click', () => { if (Player.instance.currentTrack) { handleTrackAction( 'download', Player.instance.currentTrack, Player.instance, MusicAPI.instance, lyricsManager, 'track', UIRenderer.instance ); } }); // Auto-update lyrics when track changes let previousTrackId = null; audioPlayer.addEventListener('play', async () => { if (!Player.instance.currentTrack) return; // Update UI with current track info for theme UIRenderer.instance.setCurrentTrack(Player.instance.currentTrack); // Update Media Session with new track Player.instance.updateMediaSession(Player.instance.currentTrack); const currentTrackId = Player.instance.currentTrack.id; if (currentTrackId === previousTrackId) return; previousTrackId = currentTrackId; // Update lyrics panel if it's open if (sidePanelManager.isActive('lyrics')) { // Re-open forces update/refresh of content and sync openLyricsPanel(Player.instance.currentTrack, Player.instance.activeElement, lyricsManager, true); } // Update Fullscreen if it's open const fullscreenOverlay = document.getElementById('fullscreen-cover-overlay'); if (fullscreenOverlay && getComputedStyle(fullscreenOverlay).display !== 'none') { const nextTrack = Player.instance.getNextTrack(); UIRenderer.instance.showFullscreenCover( Player.instance.currentTrack, nextTrack, lyricsManager, Player.instance.activeElement ); } // DEV: Auto-open fullscreen mode if ?fullscreen=1 in URL const urlParams = new URLSearchParams(window.location.search); if ( urlParams.get('fullscreen') === '1' && fullscreenOverlay && getComputedStyle(fullscreenOverlay).display === 'none' ) { const nextTrack = Player.instance.getNextTrack(); UIRenderer.instance.showFullscreenCover( Player.instance.currentTrack, nextTrack, lyricsManager, Player.instance.activeElement ); } }); document.addEventListener('click', async (e) => { if (e.target.closest('#play-album-btn')) { const btn = e.target.closest('#play-album-btn'); if (btn.disabled) return; const pathParts = window.location.pathname.split('/'); const albumIndex = pathParts.indexOf('album'); let albumId = albumIndex !== -1 ? pathParts[albumIndex + 1] : null; // Handle /album/t/ID format if (albumId === 't') { albumId = pathParts[albumIndex + 2]; } if (!albumId) return; try { const { tracks } = await MusicAPI.instance.getAlbum(albumId); if (tracks && tracks.length > 0) { // Sort tracks by disc and track number for consistent playback const sortedTracks = [...tracks].sort((a, b) => { const discA = a.volumeNumber ?? a.discNumber ?? 1; const discB = b.volumeNumber ?? b.discNumber ?? 1; if (discA !== discB) return discA - discB; return a.trackNumber - b.trackNumber; }); Player.instance.setQueue(sortedTracks, 0); const shuffleBtn = document.getElementById('shuffle-btn'); if (shuffleBtn) shuffleBtn.classList.remove('active'); Player.instance.shuffleActive = false; await Player.instance.playTrackFromQueue(); } } catch (error) { console.error('Failed to play album:', error); const { showNotification } = await loadDownloadsModule(); showNotification('Failed to play album'); } } if (e.target.closest('#shuffle-album-btn')) { const btn = e.target.closest('#shuffle-album-btn'); if (btn.disabled) return; const pathParts = window.location.pathname.split('/'); const albumIndex = pathParts.indexOf('album'); let albumId = albumIndex !== -1 ? pathParts[albumIndex + 1] : null; // Handle /album/t/ID format if (albumId === 't') { albumId = pathParts[albumIndex + 2]; } if (!albumId) return; try { const { tracks } = await MusicAPI.instance.getAlbum(albumId); if (tracks && tracks.length > 0) { const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5); Player.instance.setQueue(shuffledTracks, 0); const shuffleBtn = document.getElementById('shuffle-btn'); if (shuffleBtn) shuffleBtn.classList.remove('active'); Player.instance.shuffleActive = false; await Player.instance.playTrackFromQueue(); const { showNotification } = await loadDownloadsModule(); showNotification('Shuffling album'); } } catch (error) { console.error('Failed to shuffle album:', error); const { showNotification } = await loadDownloadsModule(); showNotification('Failed to shuffle album'); } } if (e.target.closest('#shuffle-artist-btn')) { const btn = e.target.closest('#shuffle-artist-btn'); if (btn.disabled) return; const artistId = window.location.pathname.split('/')[2]; if (!artistId) return; btn.disabled = true; const originalHTML = btn.innerHTML; btn.innerHTML = `${SVG_ANIMATE_SPIN(18)}Shuffling...`; try { const artist = await MusicAPI.instance.getArtist(artistId); const allReleases = [...(artist.albums || []), ...(artist.eps || [])]; const trackSet = new Set(); const allTracks = []; // Fetch full artist discography tracks (albums + EPs), deduped by track ID. const chunkSize = 8; for (let i = 0; i < allReleases.length; i += chunkSize) { const chunk = allReleases.slice(i, i + chunkSize); await Promise.all( chunk.map(async (album) => { try { const { tracks } = await MusicAPI.instance.getAlbum(album.id); tracks.forEach((track) => { if (!trackSet.has(track.id)) { trackSet.add(track.id); allTracks.push(track); } }); } catch (err) { console.warn(`Failed to fetch tracks for album ${album.title}:`, err); } }) ); } // Fallback to artist top tracks if discography fetch yields nothing. if (allTracks.length === 0 && Array.isArray(artist.tracks)) { artist.tracks.forEach((track) => { if (!trackSet.has(track.id)) { trackSet.add(track.id); allTracks.push(track); } }); } if (allTracks.length === 0) { throw new Error('No tracks found for this artist'); } const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5); Player.instance.setQueue(shuffledTracks, 0); const shuffleBtn = document.getElementById('shuffle-btn'); if (shuffleBtn) shuffleBtn.classList.remove('active'); Player.instance.shuffleActive = false; await Player.instance.playTrackFromQueue(); const { showNotification } = await loadDownloadsModule(); showNotification('Shuffling artist discography'); } catch (error) { console.error('Failed to shuffle artist tracks:', error); const { showNotification } = await loadDownloadsModule(); showNotification('Failed to shuffle artist tracks'); } finally { if (document.body.contains(btn)) { btn.disabled = false; btn.innerHTML = originalHTML; } } } if (e.target.closest('#download-mix-btn')) { const btn = e.target.closest('#download-mix-btn'); if (btn.disabled) return; const mixId = window.location.pathname.split('/')[2]; if (!mixId) return; btn.disabled = true; const originalHTML = btn.innerHTML; btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}Downloading...`; try { const { mix, tracks } = await MusicAPI.instance.getMix(mixId); const { downloadPlaylistAsZip } = await loadDownloadsModule(); await downloadPlaylistAsZip( mix, tracks, MusicAPI.instance, downloadQualitySettings.getQuality(), lyricsManager ); } catch (error) { console.error('Mix download failed:', error); alert('Failed to download mix: ' + error.message); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } if (e.target.closest('#download-playlist-btn')) { const btn = e.target.closest('#download-playlist-btn'); if (btn.disabled) return; const playlistId = window.location.pathname.split('/')[2]; if (!playlistId) return; btn.disabled = true; const originalHTML = btn.innerHTML; btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}Downloading...`; try { let playlist, tracks; let userPlaylist = await db.getPlaylist(playlistId); if (!userPlaylist) { try { userPlaylist = await syncManager.getPublicPlaylist(playlistId); } catch { // Not a public playlist } } if (userPlaylist) { playlist = { ...userPlaylist, title: userPlaylist.name || userPlaylist.title }; tracks = userPlaylist.tracks || []; } else { const data = await MusicAPI.instance.getPlaylist(playlistId); playlist = data.playlist; tracks = data.tracks; } const { downloadPlaylistAsZip } = await loadDownloadsModule(); await downloadPlaylistAsZip( playlist, tracks, MusicAPI.instance, downloadQualitySettings.getQuality(), lyricsManager ); } catch (error) { console.error('Playlist download failed:', error); alert('Failed to download playlist: ' + error.message); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } if (e.target.closest('#create-playlist-btn') || e.target.closest('#library-create-playlist-card')) { trackOpenModal('Create Playlist'); const modal = document.getElementById('playlist-modal'); document.getElementById('playlist-modal-title').textContent = 'Create Playlist'; document.getElementById('playlist-name-input').value = ''; document.getElementById('playlist-cover-input').value = ''; document.getElementById('playlist-cover-file-input').value = ''; document.getElementById('playlist-description-input').value = ''; modal.dataset.editingId = ''; document.getElementById('import-section').style.display = 'block'; document.getElementById('csv-file-input').value = ''; document.getElementById('ytm-url-input').value = ''; document.getElementById('ytm-status').textContent = ''; document.getElementById('jspf-file-input').value = ''; document.getElementById('xspf-file-input').value = ''; document.getElementById('xml-file-input').value = ''; document.getElementById('m3u-file-input').value = ''; // Reset import tabs to CSV document.querySelectorAll('.import-tab').forEach((tab) => { tab.classList.toggle('active', tab.dataset.importType === 'csv'); }); document.getElementById('csv-import-panel').style.display = 'block'; document.getElementById('jspf-import-panel').style.display = 'none'; document.getElementById('xspf-import-panel').style.display = 'none'; document.getElementById('xml-import-panel').style.display = 'none'; document.getElementById('m3u-import-panel').style.display = 'none'; // Reset Public Toggle const publicToggle = document.getElementById('playlist-public-toggle'); const shareBtn = document.getElementById('playlist-share-btn'); if (publicToggle) publicToggle.checked = false; if (shareBtn) shareBtn.style.display = 'none'; // Reset cover upload state const coverUploadBtn = document.getElementById('playlist-cover-upload-btn'); const coverUrlInput = document.getElementById('playlist-cover-input'); const coverUploadStatus = document.getElementById('playlist-cover-upload-status'); const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn'); if (coverUploadBtn) { coverUploadBtn.style.flex = '1'; coverUploadBtn.style.display = 'flex'; } if (coverUrlInput) coverUrlInput.style.display = 'none'; if (coverUploadStatus) coverUploadStatus.style.display = 'none'; if (coverToggleUrlBtn) { coverToggleUrlBtn.textContent = 'or URL'; coverToggleUrlBtn.title = 'Switch to URL input'; } modal.classList.add('active'); document.getElementById('playlist-name-input').focus(); } if (e.target.closest('#create-folder-btn') || e.target.closest('#library-create-folder-card')) { trackOpenModal('Create Folder'); const modal = document.getElementById('folder-modal'); document.getElementById('folder-name-input').value = ''; document.getElementById('folder-cover-input').value = ''; modal.classList.add('active'); document.getElementById('folder-name-input').focus(); } if (e.target.closest('#folder-modal-save')) { const name = document.getElementById('folder-name-input').value.trim(); const cover = document.getElementById('folder-cover-input').value.trim(); if (name) { const folder = await db.createFolder(name, cover); trackCreateFolder(folder); await syncManager.syncUserFolder(folder, 'create'); UIRenderer.instance.renderLibraryPage(); document.getElementById('folder-modal').classList.remove('active'); trackCloseModal('Create Folder'); } else { showNotification('Please enter a folder name.'); } } if (e.target.closest('#folder-modal-cancel')) { document.getElementById('folder-modal').classList.remove('active'); } if (e.target.closest('#library-liked-tracks-view-list')) { localStorage.setItem('libraryLikedTracksView', 'list'); if (window.location.pathname.split('/').filter(Boolean)[0] === 'library') { await UIRenderer.instance.renderLibraryPage(); } } if (e.target.closest('#library-liked-tracks-view-grid')) { localStorage.setItem('libraryLikedTracksView', 'grid'); if (window.location.pathname.split('/').filter(Boolean)[0] === 'library') { await UIRenderer.instance.renderLibraryPage(); } } if (e.target.closest('#delete-folder-btn')) { const folderId = window.location.pathname.split('/')[2]; if (folderId && confirm('Are you sure you want to delete this folder?')) { await db.deleteFolder(folderId); // Sync deletion to cloud await syncManager.syncUserFolder({ id: folderId }, 'delete'); navigate('/library'); } } if (e.target.closest('#playlist-modal-save')) { let name = document.getElementById('playlist-name-input').value.trim(); let description = document.getElementById('playlist-description-input').value.trim(); const isPublic = document.getElementById('playlist-public-toggle')?.checked; const isStrictAlbumMatch = document.getElementById('strict-album-match-toggle')?.checked; if (name) { const modal = document.getElementById('playlist-modal'); const editingId = modal.dataset.editingId; const handlePublicStatus = async (playlist) => { playlist.isPublic = isPublic; if (isPublic) { try { await syncManager.publishPlaylist(playlist); } catch (e) { console.error('Failed to publish playlist:', e); alert('Failed to publish playlist. Please ensure you are logged in.'); } } else { try { await syncManager.unpublishPlaylist(playlist.id); } catch { // Ignore error if it wasn't public } } return playlist; }; if (editingId) { // Edit const cover = document.getElementById('playlist-cover-input').value.trim(); db.getPlaylist(editingId).then(async (playlist) => { if (playlist) { playlist.name = name; playlist.cover = cover; playlist.description = description; await handlePublicStatus(playlist); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); syncManager.syncUserPlaylist(playlist, 'update'); UIRenderer.instance.renderLibraryPage(); // Also update current page if we are on it if (window.location.pathname === `/userplaylist/${editingId}`) { UIRenderer.instance.renderPlaylistPage(editingId, 'user'); } modal.classList.remove('active'); delete modal.dataset.editingId; } }); } else { // Create const csvFileInput = document.getElementById('csv-file-input'); const jspfFileInput = document.getElementById('jspf-file-input'); const xspfFileInput = document.getElementById('xspf-file-input'); const xmlFileInput = document.getElementById('xml-file-input'); const m3uFileInput = document.getElementById('m3u-file-input'); const importOptions = { strictArtistMatch: true, strictAlbumMatch: isStrictAlbumMatch }; let tracks = []; let importSource = 'manual'; let cover = document.getElementById('playlist-cover-input').value.trim(); // Helper function for import progress const setupProgressElements = () => { const progressElement = document.getElementById('csv-import-progress'); const progressFill = document.getElementById('csv-progress-fill'); const progressCurrent = document.getElementById('csv-progress-current'); const progressTotal = document.getElementById('csv-progress-total'); const currentTrackElement = progressElement.querySelector('.current-track'); const currentArtistElement = progressElement.querySelector('.current-artist'); return { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, }; }; const isYTMActive = document.getElementById('csv-ytm-btn')?.classList.contains('btn-primary'); const ytmUrlInput = document.getElementById('ytm-url-input'); if (isYTMActive && ytmUrlInput.value.trim()) { importSource = 'ytm_import'; const url = ytmUrlInput.value.trim(); const playlistId = url.split('list=')[1]?.split('&')[0]; const workerUrl = `https://ytmimport.samidy.workers.dev?playlistId=${playlistId}`; if (!playlistId) { alert("Invalid URL. Make sure it has 'list=' in it."); return; } const { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, } = setupProgressElements(); try { progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; currentTrackElement.textContent = 'Fetching from YouTube...'; if (currentArtistElement) currentArtistElement.textContent = ''; const response = await fetch(workerUrl); const songs = await response.json(); if (songs.error) throw new Error(songs.error); currentTrackElement.textContent = `Processing ${songs.length} songs...`; const headers = 'Title,Artist,URL\n'; const csvText = headers + songs .map( (s) => `"${s.title.replace(/"/g, '""')}","${s.artist.replace(/"/g, '""')}","${s.url}"` ) .join('\n'); const totalTracks = songs.length; progressTotal.textContent = totalTracks.toString(); const result = await parseCSV( csvText, MusicAPI.instance, (progress) => { const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); currentTrackElement.textContent = progress.currentTrack; if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || ''; }, importOptions ); tracks = result.tracks; const missingTracks = result.missingTracks; if (tracks.length === 0) { alert('No valid tracks found in the YouTube playlist!'); progressElement.style.display = 'none'; return; } console.log(`Imported ${tracks.length} tracks from YouTube`); trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length); if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (err) { console.error('YTM Import Error:', err); alert(`Error importing from YouTube: ${err.message}`); progressElement.style.display = 'none'; return; } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); } } else if (jspfFileInput.files.length > 0) { // Import from JSPF importSource = 'jspf_import'; const file = jspfFileInput.files[0]; const { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, } = setupProgressElements(); try { progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; currentTrackElement.textContent = 'Reading JSPF file...'; if (currentArtistElement) currentArtistElement.textContent = ''; const jspfText = await file.text(); const result = await parseJSPF(jspfText, MusicAPI.instance, (progress) => { const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); progressTotal.textContent = progress.total.toString(); currentTrackElement.textContent = progress.currentTrack; if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || ''; }); tracks = result.tracks; const missingTracks = result.missingTracks; if (tracks.length === 0) { alert('No valid tracks found in the JSPF file! Please check the format.'); progressElement.style.display = 'none'; return; } console.log(`Imported ${tracks.length} tracks from JSPF`); // Auto-fill playlist metadata from JSPF if not provided const jspfData = result.jspfData; if (jspfData && jspfData.playlist) { const playlist = jspfData.playlist; if (!name && playlist.title) { name = playlist.title; } if (!description && playlist.annotation) { description = playlist.annotation; } if (!cover && playlist.image) { cover = playlist.image; } } // Track JSPF import const jspfPlaylist = result.jspfData?.playlist; const jspfCreator = jspfPlaylist?.creator || jspfPlaylist?.extension?.['https://musicbrainz.org/doc/jspf#playlist']?.creator || 'unknown'; trackImportJSPF( name || jspfPlaylist?.title || 'Untitled', tracks.length, missingTracks.length, jspfCreator ); if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { console.error('Failed to parse JSPF!', error); alert('Failed to parse JSPF file! ' + error.message); progressElement.style.display = 'none'; return; } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); } } else if (csvFileInput.files.length > 0) { const file = csvFileInput.files[0]; const { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, } = setupProgressElements(); try { progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; currentTrackElement.textContent = 'Reading CSV file...'; if (currentArtistElement) currentArtistElement.textContent = ''; const csvText = await file.text(); const lines = csvText.trim().split('\n'); const totalItems = Math.max(0, lines.length - 1); progressTotal.textContent = totalItems.toString(); const result = await parseDynamicCSV( csvText, MusicAPI.instance, (progress) => { const percentage = totalItems > 0 ? (progress.current / totalItems) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); currentTrackElement.textContent = progress.currentItem; if (currentArtistElement) { currentArtistElement.textContent = progress.type ? `Importing ${progress.type}...` : ''; } }, importOptions ); const isLibraryImport = result.albums.length > 0 || result.artists.length > 0 || Object.keys(result.playlists).length > 1; if (isLibraryImport) { currentTrackElement.textContent = 'Adding to library...'; const importResults = await importToLibrary( result, db, (progress) => { if (progress.action === 'playlist') { currentTrackElement.textContent = `Creating playlist: ${progress.item}`; } else { currentTrackElement.textContent = `Adding ${progress.action}: ${progress.item}`; } }, { favoriteTracks: false, favoriteAlbums: false, favoriteArtists: false, } ); console.log('Import results:', importResults); const summary = []; if (importResults.tracks.added > 0) summary.push(`${importResults.tracks.added} tracks`); if (importResults.albums.added > 0) summary.push(`${importResults.albums.added} albums`); if (importResults.artists.added > 0) summary.push(`${importResults.artists.added} artists`); if (importResults.playlists.created > 0) summary.push(`${importResults.playlists.created} playlists`); alert( `Imported to library:\n${summary.join(', ')}\n\n${ result.missingItems.length > 0 ? `${result.missingItems.length} items could not be found.` : '' }` ); progressElement.style.display = 'none'; } tracks = result.tracks; const missingTracks = result.missingItems.filter((i) => i.type === 'track'); if (tracks.length === 0) { alert('No valid tracks found in the CSV file! Please check the format.'); progressElement.style.display = 'none'; return; } console.log(`Imported ${tracks.length} tracks from CSV`); trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length); if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { console.error('Failed to parse CSV!', error); alert('Failed to parse CSV file! ' + error.message); progressElement.style.display = 'none'; return; } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); } } else if (xspfFileInput.files.length > 0) { // Import from XSPF importSource = 'xspf_import'; const file = xspfFileInput.files[0]; const { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, } = setupProgressElements(); try { progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; currentTrackElement.textContent = 'Reading XSPF file...'; if (currentArtistElement) currentArtistElement.textContent = ''; const xspfText = await file.text(); const result = await parseXSPF(xspfText, MusicAPI.instance, (progress) => { const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); progressTotal.textContent = progress.total.toString(); currentTrackElement.textContent = progress.currentTrack; if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || ''; }); tracks = result.tracks; const missingTracks = result.missingTracks; if (tracks.length === 0) { alert('No valid tracks found in the XSPF file! Please check the format.'); progressElement.style.display = 'none'; return; } console.log(`Imported ${tracks.length} tracks from XSPF`); trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length); if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { console.error('Failed to parse XSPF!', error); alert('Failed to parse XSPF file! ' + error.message); progressElement.style.display = 'none'; return; } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); } } else if (xmlFileInput.files.length > 0) { // Import from XML importSource = 'xml_import'; const file = xmlFileInput.files[0]; const { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, } = setupProgressElements(); try { progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; currentTrackElement.textContent = 'Reading XML file...'; if (currentArtistElement) currentArtistElement.textContent = ''; const xmlText = await file.text(); const result = await parseXML(xmlText, MusicAPI.instance, (progress) => { const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); progressTotal.textContent = progress.total.toString(); currentTrackElement.textContent = progress.currentTrack; if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || ''; }); tracks = result.tracks; const missingTracks = result.missingTracks; if (tracks.length === 0) { alert('No valid tracks found in the XML file! Please check the format.'); progressElement.style.display = 'none'; return; } console.log(`Imported ${tracks.length} tracks from XML`); trackImportXML(name || 'Untitled', tracks.length, missingTracks.length); if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { console.error('Failed to parse XML!', error); alert('Failed to parse XML file! ' + error.message); progressElement.style.display = 'none'; return; } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); } } else if (m3uFileInput.files.length > 0) { // Import from M3U/M3U8 importSource = 'm3u_import'; const file = m3uFileInput.files[0]; const { progressElement, progressFill, progressCurrent, progressTotal, currentTrackElement, currentArtistElement, } = setupProgressElements(); try { progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; currentTrackElement.textContent = 'Reading M3U file...'; if (currentArtistElement) currentArtistElement.textContent = ''; const m3uText = await file.text(); const result = await parseM3U(m3uText, MusicAPI.instance, (progress) => { const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); progressTotal.textContent = progress.total.toString(); currentTrackElement.textContent = progress.currentTrack; if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || ''; }); tracks = result.tracks; const missingTracks = result.missingTracks; if (tracks.length === 0) { alert('No valid tracks found in the M3U file! Please check the format.'); progressElement.style.display = 'none'; return; } console.log(`Imported ${tracks.length} tracks from M3U`); trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length); if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { console.error('Failed to parse M3U!', error); alert('Failed to parse M3U file! ' + error.message); progressElement.style.display = 'none'; return; } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); } } // Check for pending tracks (from Add to Playlist -> New Playlist) const modal = document.getElementById('playlist-modal'); if (modal._pendingTracks && Array.isArray(modal._pendingTracks)) { tracks = [...tracks, ...modal._pendingTracks]; delete modal._pendingTracks; // Also clear CSV input if we came from there? No, keep it separate. console.log(`Added ${tracks.length} tracks (including pending)`); } db.createPlaylist(name, tracks, cover, description).then(async (playlist) => { await handlePublicStatus(playlist); // Update DB again with isPublic flag await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await syncManager.syncUserPlaylist(playlist, 'create'); trackCreatePlaylist(playlist, importSource); UIRenderer.instance.renderLibraryPage(); modal.classList.remove('active'); trackCloseModal('Create Playlist'); }); } } else { showNotification('Please enter a playlist name.'); } } if (e.target.closest('#playlist-modal-cancel')) { document.getElementById('playlist-modal').classList.remove('active'); } if (e.target.closest('.edit-playlist-btn')) { const card = e.target.closest('.user-playlist'); const playlistId = card.dataset.userPlaylistId; db.getPlaylist(playlistId).then(async (playlist) => { if (playlist) { const modal = document.getElementById('playlist-modal'); document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; document.getElementById('playlist-name-input').value = playlist.name; document.getElementById('playlist-cover-input').value = playlist.cover || ''; document.getElementById('playlist-description-input').value = playlist.description || ''; // Set Public Toggle const publicToggle = document.getElementById('playlist-public-toggle'); const shareBtn = document.getElementById('playlist-share-btn'); // Check if actually public in Pocketbase to be sure (async) or trust local flag // We trust local flag for UI speed, but could verify. if (publicToggle) publicToggle.checked = !!playlist.isPublic; if (shareBtn) { shareBtn.style.display = playlist.isPublic ? 'flex' : 'none'; shareBtn.onclick = () => { const url = getShareUrl(`/userplaylist/${playlist.id}`); navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!')); }; } // Set cover upload state - show URL input if there's an existing cover const coverUploadBtn = document.getElementById('playlist-cover-upload-btn'); const coverUrlInput = document.getElementById('playlist-cover-input'); const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn'); if (playlist.cover) { if (coverUploadBtn) coverUploadBtn.style.display = 'none'; if (coverUrlInput) coverUrlInput.style.display = 'block'; if (coverToggleUrlBtn) { coverToggleUrlBtn.textContent = 'Upload'; coverToggleUrlBtn.title = 'Switch to file upload'; } } else { if (coverUploadBtn) { coverUploadBtn.style.flex = '1'; coverUploadBtn.style.display = 'flex'; } if (coverUrlInput) coverUrlInput.style.display = 'none'; if (coverToggleUrlBtn) { coverToggleUrlBtn.textContent = 'or URL'; coverToggleUrlBtn.title = 'Switch to URL input'; } } modal.dataset.editingId = playlistId; document.getElementById('import-section').style.display = 'none'; modal.classList.add('active'); document.getElementById('playlist-name-input').focus(); } }); } if (e.target.closest('.delete-playlist-btn')) { const card = e.target.closest('.user-playlist'); const playlistId = card.dataset.userPlaylistId; if (confirm('Are you sure you want to delete this playlist?')) { db.deletePlaylist(playlistId).then(() => { syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); UIRenderer.instance.renderLibraryPage(); }); } } if (e.target.closest('#edit-playlist-btn')) { const playlistId = window.location.pathname.split('/')[2]; db.getPlaylist(playlistId).then((playlist) => { if (playlist) { const modal = document.getElementById('playlist-modal'); document.getElementById('playlist-modal-title').textContent = 'Edit Playlist'; document.getElementById('playlist-name-input').value = playlist.name; document.getElementById('playlist-cover-input').value = playlist.cover || ''; document.getElementById('playlist-description-input').value = playlist.description || ''; const publicToggle = document.getElementById('playlist-public-toggle'); const shareBtn = document.getElementById('playlist-share-btn'); if (publicToggle) publicToggle.checked = !!playlist.isPublic; if (shareBtn) { shareBtn.style.display = playlist.isPublic ? 'flex' : 'none'; shareBtn.onclick = () => { const url = getShareUrl(`/userplaylist/${playlist.id}`); navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!')); }; } // Set cover upload state - show URL input if there's an existing cover const coverUploadBtn = document.getElementById('playlist-cover-upload-btn'); const coverUrlInput = document.getElementById('playlist-cover-input'); const coverToggleUrlBtn = document.getElementById('playlist-cover-toggle-url-btn'); if (playlist.cover) { if (coverUploadBtn) coverUploadBtn.style.display = 'none'; if (coverUrlInput) coverUrlInput.style.display = 'block'; if (coverToggleUrlBtn) { coverToggleUrlBtn.textContent = 'Upload'; coverToggleUrlBtn.title = 'Switch to file upload'; } } else { if (coverUploadBtn) { coverUploadBtn.style.flex = '1'; coverUploadBtn.style.display = 'flex'; } if (coverUrlInput) coverUrlInput.style.display = 'none'; if (coverToggleUrlBtn) { coverToggleUrlBtn.textContent = 'or URL'; coverToggleUrlBtn.title = 'Switch to URL input'; } } modal.dataset.editingId = playlistId; document.getElementById('import-section').style.display = 'none'; modal.classList.add('active'); document.getElementById('playlist-name-input').focus(); } }); } if (e.target.closest('#delete-playlist-btn')) { const playlistId = window.location.pathname.split('/')[2]; if (confirm('Are you sure you want to delete this playlist?')) { db.deletePlaylist(playlistId).then(() => { syncManager.syncUserPlaylist({ id: playlistId }, 'delete'); navigate('/library'); }); } } if (e.target.closest('.remove-from-playlist-btn')) { e.stopPropagation(); const btn = e.target.closest('.remove-from-playlist-btn'); const playlistId = window.location.pathname.split('/')[2]; db.getPlaylist(playlistId).then(async (playlist) => { let trackId = null; let trackType = null; // Prefer ID if available (from sorted view) if (btn.dataset.trackId) { trackId = btn.dataset.trackId; trackType = btn.dataset.type || 'track'; } else if (btn.dataset.trackIndex) { // Fallback to index (legacy/unsorted) const index = parseInt(btn.dataset.trackIndex); if (playlist && playlist.tracks[index]) { trackId = playlist.tracks[index].id; trackType = playlist.tracks[index].type || 'track'; } } if (trackId) { const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType); syncManager.syncUserPlaylist(updatedPlaylist, 'update'); const scrollTop = document.querySelector('.main-content').scrollTop; await UIRenderer.instance.renderPlaylistPage(playlistId, 'user'); document.querySelector('.main-content').scrollTop = scrollTop; } }); } if (e.target.closest('#play-playlist-btn')) { const btn = e.target.closest('#play-playlist-btn'); if (btn.disabled) return; const playlistId = window.location.pathname.split('/')[2]; if (!playlistId) return; try { let tracks; const userPlaylist = await db.getPlaylist(playlistId); if (userPlaylist) { tracks = userPlaylist.tracks; } else { // Try API, if fail, try Public Pocketbase try { const { tracks: apiTracks } = await MusicAPI.instance.getPlaylist(playlistId); tracks = apiTracks; } catch (e) { const publicPlaylist = await syncManager.getPublicPlaylist(playlistId); if (publicPlaylist) { tracks = publicPlaylist.tracks; } else { throw e; } } } if (tracks.length > 0) { Player.instance.setQueue(tracks, 0); document.getElementById('shuffle-btn').classList.remove('active'); await Player.instance.playTrackFromQueue(); } } catch (error) { console.error('Failed to play playlist:', error); alert('Failed to play playlist: ' + error.message); } } if (e.target.closest('#download-album-btn')) { const btn = e.target.closest('#download-album-btn'); if (btn.disabled) return; const albumId = window.location.pathname.split('/')[2]; if (!albumId) return; btn.disabled = true; const originalHTML = btn.innerHTML; btn.innerHTML = `${SVG_ANIMATE_SPIN(20)}Downloading...`; try { const { album, tracks } = await MusicAPI.instance.getAlbum(albumId); const { downloadAlbumAsZip } = await loadDownloadsModule(); await downloadAlbumAsZip( album, tracks, MusicAPI.instance, downloadQualitySettings.getQuality(), lyricsManager ); } 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('#add-album-to-playlist-btn')) { const btn = e.target.closest('#add-album-to-playlist-btn'); if (btn.disabled) return; const albumId = window.location.pathname.split('/')[2]; if (!albumId) return; try { const { tracks } = await MusicAPI.instance.getAlbum(albumId); if (!tracks || tracks.length === 0) { const { showNotification } = await loadDownloadsModule(); showNotification('No tracks found in this album.'); return; } const modal = document.getElementById('playlist-select-modal'); const list = document.getElementById('playlist-select-list'); const cancelBtn = document.getElementById('playlist-select-cancel'); const overlay = modal.querySelector('.modal-overlay'); const playlists = await db.getPlaylists(false); list.innerHTML = `
A new version of Monochrome is available.