import { debounce } from './utils.js'; import { db } from './db.js'; import Fuse from 'fuse.js'; class CommandPalette { constructor() { this.overlay = document.getElementById('command-palette-overlay'); this.input = document.getElementById('command-palette-input'); this.resultsContainer = document.getElementById('command-palette-results'); this.isOpen = false; this.selectedIndex = 0; this.results = []; this.allSettings = []; this.debouncedSearch = debounce(this.performSearch.bind(this), 300); this.commands = [ { name: 'theme', description: 'Change theme (white, dark, ocean, purple, forest, etc.)', action: (args) => this.handleTheme(args), }, { name: 'play', description: 'Search and play a track', action: (args, autoPick) => this.handlePlay(args, autoPick), }, { name: 'shuffle', description: 'Shuffle a playlist, artist, or album', action: (args, autoPick) => this.handleShuffle(args, autoPick), }, { name: 'queue', description: 'Manage the queue (wipe, like all, download)', action: (args) => this.handleQueue(args), }, { name: 'setting', description: 'Search for a specific setting', action: (args) => this.handleSettingSearch(args), }, { name: 'sleep', description: 'Set sleep timer in minutes', action: (args) => this.handleSleepTimer(args), }, { name: 'quality', description: 'Set streaming & download quality', action: (args) => this.handleQuality(args), }, { name: 'visualizer', description: 'Control visualizer (toggle, preset)', action: (args) => this.handleVisualizer(args), }, { name: 'cache', description: 'Clear application cache', action: () => this.handleClearCache(), }, ]; this.init(); } init() { document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); this.toggle(); } }); this.input.addEventListener('input', () => this.handleInput()); this.input.addEventListener('keydown', (e) => this.handleKeydown(e)); this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) this.close(); }); this.cacheAllSettings(); } toggle() { if (this.isOpen) this.close(); else this.open(); } open() { this.isOpen = true; this.overlay.style.display = 'flex'; this.input.value = '>'; this.input.focus(); this.handleInput(); } close() { this.isOpen = false; this.overlay.style.display = 'none'; } handleInput() { const value = this.input.value; this.selectedIndex = 0; if (!value.startsWith('>')) { this.renderResults([ { name: 'Type > to use commands', description: 'e.g. >theme White, >play The Whole World Is Free', action: () => { this.input.value = '>'; this.handleInput(); }, type: 'hint', }, ]); return; } const fullQuery = value.slice(1); const match = fullQuery.match(/^(\S+)(?:\s+(.*))?$/); if (!match) { this.renderDefaultCommands(); return; } const cmdName = match[1].toLowerCase(); const args = match[2] || ''; const command = this.commands.find((c) => c.name === cmdName); if (command) { const commandsWithSubmenus = ['queue', 'go', 'visualizer', 'quality', 'sleep', 'setting']; if (commandsWithSubmenus.includes(command.name) && !args.trim()) { command.action(args); return; } this.renderResults([ { name: `Execute: ${command.name} ${args}`, description: args ? `Run ${command.name} for "${args}"` : command.description, action: () => command.action(args, ['play', 'shuffle', 'setting'].includes(command.name)), type: 'execution', }, ]); if (args.trim().length > 0 && (cmdName === 'play' || cmdName === 'shuffle')) { this.debouncedSearch(cmdName, args.trim()); } } else { this.renderDefaultCommands(cmdName); } } handleKeydown(e) { if (e.key === 'ArrowDown') { e.preventDefault(); this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1); this.updateSelection(); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.selectedIndex = Math.max(this.selectedIndex - 1, 0); this.updateSelection(); } else if (e.key === 'Enter') { e.preventDefault(); this.executeSelected(); } else if (e.key === 'Escape') { this.close(); } } renderDefaultCommands(filter = '') { let cmds = this.commands; if (filter) { if (Fuse) { const fuse = new Fuse(this.commands, { keys: ['name', 'description'] }); cmds = fuse.search(filter).map((r) => r.item); } else { cmds = this.commands.filter((c) => c.name.includes(filter)); } } this.renderResults( cmds.map((c) => ({ name: c.name, description: c.description, action: () => { this.input.value = `>${c.name} `; this.handleInput(); }, type: 'command', })) ); } renderResults(results) { this.results = results; this.resultsContainer.innerHTML = ''; if (results.length === 0) { this.resultsContainer.innerHTML = '
No results found
'; return; } results.forEach((result, index) => { const div = document.createElement('div'); div.className = `command-result-item ${index === this.selectedIndex ? 'selected' : ''}`; const imgHtml = result.image ? `` : ''; div.innerHTML = `
${imgHtml}
${result.name}${result.description || ''}
`; div.addEventListener('click', () => { this.selectedIndex = index; this.executeSelected(); }); this.resultsContainer.appendChild(div); }); } updateSelection() { const items = this.resultsContainer.querySelectorAll('.command-result-item'); items.forEach((item, index) => { if (index === this.selectedIndex) { item.classList.add('selected'); item.scrollIntoView({ block: 'nearest' }); } else { item.classList.remove('selected'); } }); } executeSelected() { const result = this.results[this.selectedIndex]; if (result && result.action) { result.action(); if (result.type !== 'hint') { this.close(); } } else if (result && result.type === 'command') { this.input.value = `>${result.name} `; this.handleInput(); } } handleTheme(args) { if (!args) return; const theme = args.trim().toLowerCase(); document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); const themeOptions = document.querySelectorAll('.theme-option'); themeOptions.forEach((opt) => { if (opt.dataset.theme === theme) opt.classList.add('active'); else opt.classList.remove('active'); }); } async showNotification(message) { const { showNotification } = await import('./downloads.js'); showNotification(message); } handleQueue(args) { const player = window.monochromePlayer; const ui = window.monochromeUi; if (!player || !ui) { console.error('Player or UI not available for queue command'); return; } if (!args || !args.trim()) { this.renderResults( [ { name: '>queue wipe', description: 'Clear the queue and stop playback' }, { name: '>queue like all', description: 'Like all tracks in the current queue' }, { name: '>queue download', description: 'Download all tracks in the current queue' }, ].map((c) => ({ ...c, type: 'command', action: () => { this.input.value = c.name; this.handleInput(); }, })) ); return; } const subCommand = args.trim().toLowerCase(); switch (subCommand) { case 'wipe': player.wipeQueue(); this.showNotification('Queue wiped.'); this.close(); break; case 'like all': this.likeAllInQueue(player, ui); break; case 'download': this.downloadQueue(player, ui); break; default: this.showNotification(`Unknown queue command: ${subCommand}`); break; } } async likeAllInQueue(player, ui) { const queue = player.getCurrentQueue(); if (queue.length === 0) { this.showNotification('Queue is empty.'); return; } const { handleTrackAction } = await import('./events.js'); const scrobbler = window.monochromeScrobbler; let likedCount = 0; this.showNotification('Liking all tracks in queue...'); for (const track of queue) { const isLiked = await db.isFavorite('track', track.id); if (!isLiked) { await handleTrackAction('toggle-like', track, player, ui.api, ui.lyricsManager, 'track', ui, scrobbler); likedCount++; } } this.showNotification(`Liked ${likedCount} new track(s) in the queue.`); this.close(); } async downloadQueue(player, ui) { const queue = player.getCurrentQueue(); if (queue.length === 0) { this.showNotification('Queue is empty.'); return; } const { downloadTracks } = await import('./downloads.js'); const { downloadQualitySettings } = await import('./storage.js'); const lyricsManager = ui.lyricsManager; downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), lyricsManager); this.close(); } handleNavigation(args) { const validPages = ['home', 'library', 'recent', 'settings', 'unreleased', 'about', 'download']; if (!args || !args.trim()) { this.renderResults( validPages.map((p) => ({ name: `>go ${p}`, description: `Navigate to ${p}`, action: () => { this.close(); import('./router.js').then((m) => m.navigate(p === 'home' ? '/' : `/${p}`)); }, type: 'command', })) ); return; } const page = args.trim().toLowerCase(); if (validPages.includes(page)) { this.close(); import('./router.js').then((m) => m.navigate(page === 'home' ? '/' : `/${page}`)); } else { this.showNotification(`Unknown page: ${page}`); } } handleSleepTimer(args) { if (!args || !args.trim()) { this.renderResults( [15, 30, 45, 60, 120].map((m) => ({ name: `>sleep ${m}`, description: `Set sleep timer for ${m} minutes`, action: () => { this.setSleepTimer(m); this.close(); }, type: 'command', })) ); return; } const minutes = parseInt(args.trim()); if (!isNaN(minutes) && minutes > 0) { this.setSleepTimer(minutes); this.close(); } else { this.showNotification('Invalid duration'); } } setSleepTimer(minutes) { if (window.monochromePlayer) { window.monochromePlayer.setSleepTimer(minutes); this.showNotification(`Sleep timer set for ${minutes} minutes`); } } handleQuality(args) { const qualityMap = { low: 'LOW', high: 'HIGH', lossless: 'LOSSLESS', hires: 'HI_RES_LOSSLESS', 'hi-res': 'HI_RES_LOSSLESS', master: 'HI_RES_LOSSLESS', }; const displayQualities = [ { id: 'low', name: 'Low', code: 'LOW' }, { id: 'high', name: 'High', code: 'HIGH' }, { id: 'lossless', name: 'Lossless', code: 'LOSSLESS' }, { id: 'hi-res', name: 'Hi-Res', code: 'HI_RES_LOSSLESS' }, ]; if (!args || !args.trim()) { const results = displayQualities.map((q) => ({ name: `>quality ${q.id}`, description: `Set quality to ${q.name}`, action: () => { this.setQuality(q.code, true, true); this.close(); }, type: 'command', })); results.push({ name: 'Usage: >quality [level] [-S] [-D]', description: '-S for Streaming only, -D for Download only', action: () => {}, type: 'hint', }); this.renderResults(results); return; } const parts = args.trim().split(/\s+/); const qualityKey = parts.find((p) => !p.startsWith('-'))?.toLowerCase(); const flags = parts.filter((p) => p.startsWith('-')).map((f) => f.toLowerCase()); if (!qualityKey || !qualityMap[qualityKey]) { this.showNotification('Invalid quality setting'); return; } const qualityCode = qualityMap[qualityKey]; let setStreaming = true; let setDownload = true; if (flags.includes('-d') && !flags.includes('-s')) { setStreaming = false; } else if (flags.includes('-s') && !flags.includes('-d')) { setDownload = false; } this.setQuality(qualityCode, setStreaming, setDownload); this.close(); } async setQuality(quality, setStreaming, setDownload) { const messages = []; const qualityName = this.getQualityName(quality); if (setStreaming) { if (window.monochromePlayer) { window.monochromePlayer.setQuality(quality); localStorage.setItem('playback-quality', quality); messages.push('Streaming'); const streamingSelect = document.getElementById('streaming-quality-setting'); if (streamingSelect) { streamingSelect.value = quality; } } } if (setDownload) { const { downloadQualitySettings } = await import('./storage.js'); downloadQualitySettings.setQuality(quality); messages.push('Download'); const downloadSelect = document.getElementById('download-quality-setting'); if (downloadSelect) { downloadSelect.value = quality; } } if (messages.length > 0) { this.showNotification(`${messages.join(' & ')} quality set to ${qualityName}`); } } getQualityName(code) { const names = { LOW: 'Low', HIGH: 'High', LOSSLESS: 'Lossless', HI_RES_LOSSLESS: 'Hi-Res', }; return names[code] || code; } async handleVisualizer(args) { if (!args || !args.trim()) { this.renderResults( [ { name: '>visualizer toggle', description: 'Toggle visualizer on/off', cmd: 'toggle' }, { name: '>visualizer butterchurn', description: 'Set preset to Butterchurn', cmd: 'butterchurn' }, { name: '>visualizer kawarp', description: 'Set preset to Kawarp', cmd: 'kawarp' }, { name: '>visualizer lcd', description: 'Set preset to LCD', cmd: 'lcd' }, { name: '>visualizer particles', description: 'Set preset to Particles', cmd: 'particles' }, { name: '>visualizer unknown-pleasures', description: 'Set preset to Unknown Pleasures', cmd: 'unknown-pleasures', }, ].map((c) => ({ ...c, action: () => { if (c.cmd === 'toggle') { this.toggleVisualizer(); } else { this.setVisualizerPreset(c.cmd); } this.close(); }, type: 'command', })) ); return; } const subCmd = args.trim().toLowerCase(); if (subCmd === 'toggle') { this.toggleVisualizer(); this.close(); } else { const presets = ['butterchurn', 'kawarp', 'lcd', 'particles', 'unknown-pleasures']; if (presets.includes(subCmd)) { this.setVisualizerPreset(subCmd); this.close(); } else { this.showNotification('Unknown visualizer command'); } } } async toggleVisualizer() { const { visualizerSettings } = await import('./storage.js'); const current = visualizerSettings.isEnabled(); visualizerSettings.setEnabled(!current); this.showNotification(`Visualizer ${!current ? 'enabled' : 'disabled'}`); const overlay = document.getElementById('fullscreen-cover-overlay'); if (overlay && getComputedStyle(overlay).display !== 'none') { window.monochromeUi?.closeFullscreenCover(); } } async setVisualizerPreset(preset) { const { visualizerSettings } = await import('./storage.js'); visualizerSettings.setPreset(preset); if (window.monochromeUi?.visualizer) { window.monochromeUi.visualizer.setPreset(preset); } this.showNotification(`Visualizer preset set to ${preset}`); } async handleClearCache() { const api = window.monochromeUi?.api; if (api) { await api.clearCache(); this.showNotification('Cache cleared'); this.close(); } } cacheAllSettings() { const settingItems = document.querySelectorAll('#page-settings .setting-item'); this.allSettings = Array.from(settingItems) .map((item) => { const labelEl = item.querySelector('.label'); const descEl = item.querySelector('.description'); const tabEl = item.closest('.settings-tab-content'); const label = labelEl ? labelEl.textContent.trim() : ''; const description = descEl ? descEl.textContent.trim() : ''; const tab = tabEl ? tabEl.id.replace('settings-tab-', '') : ''; if (!item.id) { const inputEl = item.querySelector('input[id], select[id], button[id]'); item.id = inputEl ? `setting-item-for-${inputEl.id}` : `setting-item-${Math.random().toString(36).substr(2, 9)}`; } return { id: item.id, label, description, tab, }; }) .filter((s) => s.label); } async handleSettingSearch(args, autoPick = false) { const query = args.trim().toLowerCase(); if (!query) { this.renderResults( this.allSettings.map((setting) => ({ name: setting.label, description: `[${setting.tab}] ${setting.description}`, action: () => { this.navigateToSetting(setting); this.close(); }, type: 'setting', })) ); return; } const fuse = new Fuse(this.allSettings, { keys: ['label', 'description'], includeScore: true, threshold: 0.4, ignoreLocation: true, }); const results = fuse.search(query).map((r) => r.item); if (autoPick && results.length > 0) { this.navigateToSetting(results[0]); this.close(); return; } this.renderResults( results.map((setting) => ({ name: setting.label, description: `[${setting.tab}] ${setting.description}`, action: () => { this.navigateToSetting(setting); this.close(); }, type: 'setting', })) ); } async navigateToSetting(setting) { const router = await import('./router.js'); router.navigate('/settings'); await new Promise((resolve) => setTimeout(resolve, 100)); const tabButton = document.querySelector(`.settings-tab[data-tab="${setting.tab}"]`); if (tabButton && !tabButton.classList.contains('active')) { tabButton.click(); } await new Promise((resolve) => setTimeout(resolve, 50)); const settingElement = document.getElementById(setting.id); if (settingElement) { settingElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); settingElement.style.transition = 'background-color 0.3s ease-out, box-shadow 0.3s ease-out'; settingElement.style.backgroundColor = 'rgba(var(--highlight-rgb), 0.2)'; settingElement.style.boxShadow = '0 0 0 2px rgba(var(--highlight-rgb), 0.5)'; setTimeout(() => { settingElement.style.backgroundColor = ''; settingElement.style.boxShadow = ''; }, 2000); } } async performSearch(cmdName, query) { if (!this.isOpen) return; const api = window.monochromeUi?.api; if (!api) return; let results = []; try { if (cmdName === 'play') { const data = await api.searchTracks(query); results = data.items.map((track) => ({ name: track.title, description: `${track.artist?.name || 'Unknown'} • ${track.album?.title || 'Unknown'}`, image: api.getCoverUrl(track.album?.cover, 80), action: () => { window.monochromePlayer.setQueue([track], 0); window.monochromePlayer.playTrackFromQueue(); this.close(); }, type: 'result', })); } else if (cmdName === 'shuffle') { const [albums, artists, playlists, userPlaylists] = await Promise.all([ api.searchAlbums(query), api.searchArtists(query), api.searchPlaylists(query), db.getPlaylists(true), ]); let matchedUserPlaylists = []; if (Fuse) { const fuse = new Fuse(userPlaylists, { keys: ['name'] }); matchedUserPlaylists = fuse.search(query).map((r) => r.item); } else { matchedUserPlaylists = userPlaylists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()) ); } const formatResult = (item, type, subtitle, image) => ({ name: item.title || item.name, description: `${type} • ${subtitle}`, image: image, action: () => this.playCollection(item, type, true), type: 'result', }); results = [ ...matchedUserPlaylists.map((p) => formatResult( p, 'User Playlist', `${p.tracks?.length || 0} tracks`, p.cover || (p.images && p.images[0]) ) ), ...artists.items.map((a) => formatResult(a, 'Artist', 'Artist', api.getArtistPictureUrl(a.picture, 80)) ), ...albums.items.map((a) => formatResult(a, 'Album', a.artist?.name, api.getCoverUrl(a.cover, 80))), ...playlists.items.map((p) => formatResult(p, 'Playlist', p.creator?.name || 'Tidal', api.getCoverUrl(p.image, 80)) ), ]; } } catch (e) { console.error('Command palette search error:', e); } if (this.isOpen && results.length > 0) { this.renderResults(results); } } async handlePlay(args, autoPick) { if (!args) return; if (autoPick) { const api = window.monochromeUi?.api; const results = await api.searchTracks(args); if (results.items.length > 0) { const track = results.items[0]; window.monochromePlayer.setQueue([track], 0); window.monochromePlayer.playTrackFromQueue(); this.close(); } } } async handleShuffle(args, autoPick) { if (!args) return; if (autoPick) { this.performSearch('shuffle', args).then(() => { if (this.results.length > 0 && this.results[0].action) { this.results[0].action(); } }); } } async playCollection(item, type, shuffle) { const player = window.monochromePlayer; const api = window.monochromeUi.api; let tracks = []; try { if (type === 'User Playlist') { tracks = item.tracks; } else if (type === 'Artist') { const artist = await api.getArtist(item.id); const allReleases = [...(artist.albums || []), ...(artist.eps || [])]; const trackSet = new Set(); const allTracks = []; 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: albumTracks } = await api.getAlbum(album.id); albumTracks.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); } }) ); } if (allTracks.length > 0) { tracks = allTracks; } else { tracks = artist.tracks || []; } } else if (type === 'Album') { tracks = (await api.getAlbum(item.id)).tracks; } else if (type === 'Playlist') { tracks = (await api.getPlaylist(item.uuid)).tracks; } if (tracks && tracks.length > 0) { if (shuffle) { tracks = [...tracks].sort(() => Math.random() - 0.5); player.shuffleActive = true; document.getElementById('shuffle-btn')?.classList.add('active'); } player.setQueue(tracks, 0); player.playTrackFromQueue(); this.close(); } } catch (e) { console.error('Failed to play collection:', e); } } } new CommandPalette();