From 3415901bdb797358f4b947a31d587acef17b00d8 Mon Sep 17 00:00:00 2001 From: akane <107654710+genericness@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:35:09 -0700 Subject: [PATCH 1/4] feat(ui): add cmdk-style command palette --- index.html | 24 +- js/commandPalette.js | 1778 +++++++++++++++++++++++++----------------- styles.css | 278 ++++++- 3 files changed, 1351 insertions(+), 729 deletions(-) diff --git a/index.html b/index.html index 68f6935..4ef6d04 100644 --- a/index.html +++ b/index.html @@ -1484,14 +1484,36 @@ diff --git a/js/commandPalette.js b/js/commandPalette.js index 154f477..2fce066 100644 --- a/js/commandPalette.js +++ b/js/commandPalette.js @@ -3,6 +3,72 @@ import { db } from './db.js'; import Fuse from 'fuse.js'; import { navigate } from './router.js'; +const ICONS = { + search: '', + house: '', + library: + '', + clock: '', + calendar: + '', + settings: + '', + info: '', + download: + '', + heart: '', + play: '', + pause: '', + skipForward: + '', + skipBack: + '', + shuffle: + '', + repeat: '', + volumeX: + '', + volume: '', + list: '', + trash: '', + text: '', + maximize: + '', + sparkles: + '', + palette: + '', + sun: '', + moon: '', + sliders: + '', + plus: '', + folderPlus: + '', + user: '', + logOut: '', + logIn: '', + keyboard: + '', + music: '', + disc: '', + mic: '', + upload: '', + handHeart: + '', + monitor: + '', + pencil: '', + radio: '', + store: '', +}; + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + class CommandPalette { constructor() { this.overlay = document.getElementById('command-palette-overlay'); @@ -10,72 +76,603 @@ class CommandPalette { this.resultsContainer = document.getElementById('command-palette-results'); this.isOpen = false; this.selectedIndex = 0; - this.results = []; - + this.flatItems = []; 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.musicSearchAbort = null; + this.debouncedMusicSearch = debounce(this.searchMusic.bind(this), 300); + this.commands = this.buildCommands(); + this.fuse = new Fuse(this.commands, { + keys: [ + { name: 'label', weight: 0.6 }, + { name: 'keywords', weight: 0.3 }, + { name: 'group', weight: 0.1 }, + ], + threshold: 0.4, + ignoreLocation: true, + includeScore: true, + }); this.init(); } + buildCommands() { + return [ + { + id: 'nav-home', + group: 'Navigation', + icon: 'house', + label: 'Go to Home', + keywords: ['home', 'main', 'start', 'landing'], + action: () => { + navigate('/'); + }, + }, + { + id: 'nav-library', + group: 'Navigation', + icon: 'library', + label: 'Go to Library', + keywords: ['library', 'collection', 'playlists', 'favorites'], + action: () => { + navigate('/library'); + }, + }, + { + id: 'nav-recent', + group: 'Navigation', + icon: 'clock', + label: 'Go to Recent', + keywords: ['recent', 'history', 'last played'], + action: () => { + navigate('/recent'); + }, + }, + { + id: 'nav-unreleased', + group: 'Navigation', + icon: 'calendar', + label: 'Go to Unreleased', + keywords: ['unreleased', 'upcoming', 'tracker'], + action: () => { + navigate('/unreleased'); + }, + }, + { + id: 'nav-settings', + group: 'Navigation', + icon: 'settings', + label: 'Go to Settings', + keywords: ['settings', 'preferences', 'config', 'options'], + shortcut: null, + action: () => { + navigate('/settings'); + }, + }, + { + id: 'nav-about', + group: 'Navigation', + icon: 'info', + label: 'Go to About', + keywords: ['about', 'version', 'credits'], + action: () => { + navigate('/about'); + }, + }, + { + id: 'nav-download', + group: 'Navigation', + icon: 'download', + label: 'Go to Download', + keywords: ['download', 'desktop', 'app'], + action: () => { + navigate('/download'); + }, + }, + { + id: 'nav-donate', + group: 'Navigation', + icon: 'handHeart', + label: 'Go to Donate', + keywords: ['donate', 'support', 'contribute'], + action: () => { + navigate('/donate'); + }, + }, + + { + id: 'play-pause', + group: 'Playback', + icon: 'play', + label: 'Play / Pause', + keywords: ['play', 'pause', 'toggle', 'resume', 'stop'], + shortcut: 'Space', + action: () => { + window.monochromePlayer?.handlePlayPause(); + }, + }, + { + id: 'play-next', + group: 'Playback', + icon: 'skipForward', + label: 'Next Track', + keywords: ['next', 'skip', 'forward'], + shortcut: 'Shift+→', + action: () => { + window.monochromePlayer?.playNext(); + }, + }, + { + id: 'play-prev', + group: 'Playback', + icon: 'skipBack', + label: 'Previous Track', + keywords: ['previous', 'back', 'rewind'], + shortcut: 'Shift+←', + action: () => { + window.monochromePlayer?.playPrev(); + }, + }, + { + id: 'play-shuffle', + group: 'Playback', + icon: 'shuffle', + label: 'Toggle Shuffle', + keywords: ['shuffle', 'random'], + shortcut: 'S', + action: () => { + document.getElementById('shuffle-btn')?.click(); + }, + }, + { + id: 'play-repeat', + group: 'Playback', + icon: 'repeat', + label: 'Toggle Repeat', + keywords: ['repeat', 'loop', 'cycle'], + shortcut: 'R', + action: () => { + document.getElementById('repeat-btn')?.click(); + }, + }, + { + id: 'play-mute', + group: 'Playback', + icon: 'volumeX', + label: 'Mute / Unmute', + keywords: ['mute', 'unmute', 'sound', 'volume', 'silent'], + shortcut: 'M', + action: () => { + const el = window.monochromePlayer?.activeElement; + if (el) el.muted = !el.muted; + }, + }, + { + id: 'play-vol-up', + group: 'Playback', + icon: 'volume', + label: 'Volume Up', + keywords: ['volume', 'louder'], + shortcut: '↑', + action: () => { + const p = window.monochromePlayer; + if (p) p.setVolume(p.userVolume + 0.1); + }, + }, + { + id: 'play-vol-down', + group: 'Playback', + icon: 'volume', + label: 'Volume Down', + keywords: ['volume', 'quieter', 'softer'], + shortcut: '↓', + action: () => { + const p = window.monochromePlayer; + if (p) p.setVolume(p.userVolume - 0.1); + }, + }, + + { + id: 'like-current', + group: 'Now Playing', + icon: 'heart', + label: 'Like Current Track', + keywords: ['like', 'favorite', 'love', 'heart', 'save'], + action: () => { + document.querySelector('.now-playing-bar .like-btn')?.click(); + }, + }, + { + id: 'download-current', + group: 'Now Playing', + icon: 'download', + label: 'Download Current Track', + keywords: ['download', 'save', 'current'], + action: () => { + document.querySelector('.now-playing-bar .download-btn')?.click(); + }, + }, + + { + id: 'queue-open', + group: 'Queue', + icon: 'list', + label: 'Open Queue', + keywords: ['queue', 'list', 'up next'], + shortcut: 'Q', + action: () => { + document.getElementById('queue-btn')?.click(); + }, + }, + { + id: 'queue-wipe', + group: 'Queue', + icon: 'trash', + label: 'Clear Queue', + keywords: ['wipe', 'clear', 'empty', 'queue'], + action: () => { + window.monochromePlayer?.wipeQueue(); + this.notify('Queue cleared'); + }, + }, + { + id: 'queue-like-all', + group: 'Queue', + icon: 'heart', + label: 'Like All in Queue', + keywords: ['like', 'all', 'queue', 'heart', 'favorite'], + action: () => this.likeAllInQueue(), + }, + { + id: 'queue-download', + group: 'Queue', + icon: 'download', + label: 'Download Queue', + keywords: ['download', 'queue', 'save', 'all'], + action: () => this.downloadQueue(), + }, + + { + id: 'lyrics-toggle', + group: 'View', + icon: 'text', + label: 'Toggle Lyrics', + keywords: ['lyrics', 'words', 'text', 'karaoke'], + shortcut: 'L', + action: () => { + document.querySelector('.now-playing-bar .cover')?.click(); + }, + }, + { + id: 'fullscreen-open', + group: 'View', + icon: 'maximize', + label: 'Open Fullscreen View', + keywords: ['fullscreen', 'expand', 'immersive', 'cover'], + action: () => { + const cover = document.querySelector('.now-playing-bar .cover-art'); + if (cover) cover.click(); + }, + }, + { + id: 'vis-toggle', + group: 'View', + icon: 'sparkles', + label: 'Toggle Visualizer', + keywords: ['visualizer', 'visual', 'animation', 'effects'], + action: () => this.toggleVisualizer(), + }, + { + id: 'vis-butterchurn', + group: 'View', + icon: 'sparkles', + label: 'Visualizer: Butterchurn', + keywords: ['butterchurn', 'milkdrop', 'preset', 'visualizer'], + action: () => this.setVisualizerPreset('butterchurn'), + }, + { + id: 'vis-kawarp', + group: 'View', + icon: 'sparkles', + label: 'Visualizer: Kawarp', + keywords: ['kawarp', 'preset', 'visualizer'], + action: () => this.setVisualizerPreset('kawarp'), + }, + { + id: 'vis-lcd', + group: 'View', + icon: 'sparkles', + label: 'Visualizer: LCD', + keywords: ['lcd', 'preset', 'visualizer'], + action: () => this.setVisualizerPreset('lcd'), + }, + { + id: 'vis-particles', + group: 'View', + icon: 'sparkles', + label: 'Visualizer: Particles', + keywords: ['particles', 'preset', 'visualizer'], + action: () => this.setVisualizerPreset('particles'), + }, + { + id: 'vis-unknown', + group: 'View', + icon: 'sparkles', + label: 'Visualizer: Unknown Pleasures', + keywords: ['unknown pleasures', 'preset', 'visualizer', 'joy division'], + action: () => this.setVisualizerPreset('unknown-pleasures'), + }, + + { + id: 'theme-system', + group: 'Theme', + icon: 'monitor', + label: 'Theme: System', + keywords: ['theme', 'system', 'auto', 'default'], + action: () => this.setTheme('system'), + }, + { + id: 'theme-black', + group: 'Theme', + icon: 'moon', + label: 'Theme: Monochrome', + keywords: ['theme', 'monochrome', 'black', 'dark', 'amoled'], + action: () => this.setTheme('monochrome'), + }, + { + id: 'theme-dark', + group: 'Theme', + icon: 'moon', + label: 'Theme: Dark', + keywords: ['theme', 'dark'], + action: () => this.setTheme('dark'), + }, + { + id: 'theme-white', + group: 'Theme', + icon: 'sun', + label: 'Theme: White', + keywords: ['theme', 'white', 'light'], + action: () => this.setTheme('white'), + }, + { + id: 'theme-ocean', + group: 'Theme', + icon: 'palette', + label: 'Theme: Ocean', + keywords: ['theme', 'ocean', 'blue', 'sea'], + action: () => this.setTheme('ocean'), + }, + { + id: 'theme-purple', + group: 'Theme', + icon: 'palette', + label: 'Theme: Purple', + keywords: ['theme', 'purple', 'violet'], + action: () => this.setTheme('purple'), + }, + { + id: 'theme-forest', + group: 'Theme', + icon: 'palette', + label: 'Theme: Forest', + keywords: ['theme', 'forest', 'green', 'nature'], + action: () => this.setTheme('forest'), + }, + { + id: 'theme-mocha', + group: 'Theme', + icon: 'palette', + label: 'Theme: Mocha', + keywords: ['theme', 'mocha', 'catppuccin', 'brown', 'warm'], + action: () => this.setTheme('mocha'), + }, + { + id: 'theme-macchiato', + group: 'Theme', + icon: 'palette', + label: 'Theme: Macchiato', + keywords: ['theme', 'macchiato', 'catppuccin'], + action: () => this.setTheme('machiatto'), + }, + { + id: 'theme-frappe', + group: 'Theme', + icon: 'palette', + label: 'Theme: Frappé', + keywords: ['theme', 'frappe', 'catppuccin'], + action: () => this.setTheme('frappe'), + }, + { + id: 'theme-latte', + group: 'Theme', + icon: 'palette', + label: 'Theme: Latte', + keywords: ['theme', 'latte', 'catppuccin', 'light'], + action: () => this.setTheme('latte'), + }, + { + id: 'theme-store', + group: 'Theme', + icon: 'store', + label: 'Open Theme Store', + keywords: ['theme', 'store', 'browse', 'community', 'custom'], + action: () => { + document.getElementById('open-theme-store')?.click(); + }, + }, + + { + id: 'quality-low', + group: 'Audio', + icon: 'sliders', + label: 'Quality: Low', + keywords: ['quality', 'low', 'streaming', 'bitrate'], + action: () => this.setQuality('LOW'), + }, + { + id: 'quality-high', + group: 'Audio', + icon: 'sliders', + label: 'Quality: High', + keywords: ['quality', 'high', 'streaming', 'bitrate'], + action: () => this.setQuality('HIGH'), + }, + { + id: 'quality-lossless', + group: 'Audio', + icon: 'sliders', + label: 'Quality: Lossless', + keywords: ['quality', 'lossless', 'flac', 'cd', 'streaming'], + action: () => this.setQuality('LOSSLESS'), + }, + { + id: 'quality-hires', + group: 'Audio', + icon: 'sliders', + label: 'Quality: Hi-Res', + keywords: ['quality', 'hires', 'hi-res', 'master', 'mqa', 'streaming'], + action: () => this.setQuality('HI_RES_LOSSLESS'), + }, + { + id: 'sleep-15', + group: 'Audio', + icon: 'clock', + label: 'Sleep Timer: 15 min', + keywords: ['sleep', 'timer', '15', 'minutes'], + action: () => this.setSleepTimer(15), + }, + { + id: 'sleep-30', + group: 'Audio', + icon: 'clock', + label: 'Sleep Timer: 30 min', + keywords: ['sleep', 'timer', '30', 'minutes'], + action: () => this.setSleepTimer(30), + }, + { + id: 'sleep-60', + group: 'Audio', + icon: 'clock', + label: 'Sleep Timer: 60 min', + keywords: ['sleep', 'timer', '60', 'minutes', 'hour'], + action: () => this.setSleepTimer(60), + }, + { + id: 'sleep-120', + group: 'Audio', + icon: 'clock', + label: 'Sleep Timer: 120 min', + keywords: ['sleep', 'timer', '120', 'minutes', 'hours'], + action: () => this.setSleepTimer(120), + }, + + { + id: 'lib-create-playlist', + group: 'Library', + icon: 'plus', + label: 'Create Playlist', + keywords: ['create', 'new', 'playlist', 'add'], + action: () => this.createPlaylist(), + }, + { + id: 'lib-create-folder', + group: 'Library', + icon: 'folderPlus', + label: 'Create Folder', + keywords: ['create', 'new', 'folder', 'add', 'organize'], + action: () => this.createFolder(), + }, + + { + id: 'sys-cache', + group: 'System', + icon: 'trash', + label: 'Clear Cache', + keywords: ['cache', 'clear', 'reset', 'clean'], + action: () => this.clearCache(), + }, + { + id: 'sys-shortcuts', + group: 'System', + icon: 'keyboard', + label: 'View Keyboard Shortcuts', + keywords: ['keyboard', 'shortcuts', 'keys', 'hotkeys', 'bindings'], + action: () => { + document.getElementById('shortcuts-modal')?.style.setProperty('display', 'flex'); + }, + }, + { + id: 'sys-export', + group: 'System', + icon: 'upload', + label: 'Export Data', + keywords: ['export', 'backup', 'data', 'save'], + action: () => this.navigateToSetting({ tab: 'system', id: 'export-data-btn' }), + }, + { + id: 'sys-search-setting', + group: 'System', + icon: 'search', + label: 'Search Settings...', + keywords: ['setting', 'find', 'search', 'preference', 'option', 'configure'], + action: () => this.enterSettingsMode(), + }, + + { + id: 'acc-profile', + group: 'Account', + icon: 'user', + label: 'View Profile', + keywords: ['profile', 'account', 'user', 'me'], + action: () => { + document.querySelector('.user-avatar-btn')?.click(); + }, + }, + { + id: 'acc-edit-profile', + group: 'Account', + icon: 'pencil', + label: 'Edit Profile', + keywords: ['edit', 'profile', 'username', 'avatar', 'display name'], + action: async () => { + const { openEditProfile } = await import('./profile.js'); + openEditProfile(); + }, + }, + { + id: 'acc-sign-out', + group: 'Account', + icon: 'logOut', + label: 'Sign Out', + keywords: ['sign out', 'log out', 'logout', 'disconnect'], + action: async () => { + const { authManager } = await import('./accounts/auth.js'); + await authManager.signOut(); + }, + }, + { + id: 'acc-sign-in', + group: 'Account', + icon: 'logIn', + label: 'Sign In', + keywords: ['sign in', 'log in', 'login', 'account', 'connect'], + action: () => { + navigate('/account'); + }, + }, + ]; + } + init() { - document.addEventListener('keydown', async (e) => { + document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); - await this.toggle(); + this.toggle(); } }); this.input.addEventListener('input', () => this.handleInput()); - this.input.addEventListener('keydown', async (e) => await this.handleKeydown(e)); + this.input.addEventListener('keydown', (e) => this.handleKeydown(e)); this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) this.close(); @@ -84,84 +681,60 @@ class CommandPalette { this.cacheAllSettings(); } - async toggle() { + toggle() { if (this.isOpen) this.close(); - else await this.open(); + else this.open(); } - async open() { + open() { this.isOpen = true; + this.settingsMode = false; this.overlay.style.display = 'flex'; - this.input.value = '>'; + this.input.value = ''; + this.input.placeholder = 'Search commands, music, settings...'; this.input.focus(); - await this.handleInput(); + this.showDefaultCommands(); } close() { this.isOpen = false; + this.settingsMode = false; this.overlay.style.display = 'none'; + this.cancelMusicSearch(); } - async handleInput() { - const value = this.input.value; + enterSettingsMode() { + this.settingsMode = true; + this.input.value = ''; + this.input.placeholder = 'Search settings...'; + this.input.focus(); + this.cacheAllSettings(); + this.renderSettingsResults(''); + } + + handleInput() { + const query = this.input.value.trim(); this.selectedIndex = 0; - if (!value.startsWith('>')) { - await this.renderResults([ - { - name: 'Type > to use commands', - description: 'e.g. >theme White, >play The Whole World Is Free', - action: async () => { - this.input.value = '>'; - await this.handleInput(); - }, - type: 'hint', - }, - ]); + if (this.settingsMode) { + this.renderSettingsResults(query); return; } - const fullQuery = value.slice(1); - const match = fullQuery.match(/^(\S+)(?:\s+(.*))?$/); - - if (!match) { - await this.renderDefaultCommands(); + if (!query) { + this.cancelMusicSearch(); + this.showDefaultCommands(); 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; - } - - await 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 { - await this.renderDefaultCommands(cmdName); - } + this.searchCommands(query); + this.debouncedMusicSearch(query); } - async handleKeydown(e) { + handleKeydown(e) { if (e.key === 'ArrowDown') { e.preventDefault(); - this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1); + this.selectedIndex = Math.min(this.selectedIndex + 1, this.flatItems.length - 1); this.updateSelection(); } else if (e.key === 'ArrowUp') { e.preventDefault(); @@ -169,433 +742,319 @@ class CommandPalette { this.updateSelection(); } else if (e.key === 'Enter') { e.preventDefault(); - await this.executeSelected(); + this.executeSelected(); } else if (e.key === 'Escape') { - this.close(); - } - } - - async 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); + if (this.settingsMode) { + this.settingsMode = false; + this.input.value = ''; + this.input.placeholder = 'Search commands, music, settings...'; + this.showDefaultCommands(); } else { - cmds = this.commands.filter((c) => c.name.includes(filter)); + this.close(); } + } else if (e.key === 'Backspace' && this.settingsMode && !this.input.value) { + this.settingsMode = false; + this.input.placeholder = 'Search commands, music, settings...'; + this.showDefaultCommands(); } - - await this.renderResults( - cmds.map((c) => ({ - name: c.name, - description: c.description, - action: async () => { - this.input.value = `>${c.name} `; - await this.handleInput(); - }, - type: 'command', - })) - ); } - async renderResults(results) { - this.results = results; - this.resultsContainer.innerHTML = ''; + showDefaultCommands() { + const groups = this.groupBy( + this.commands.filter((c) => { + const priority = [ + 'nav-home', + 'nav-library', + 'nav-settings', + 'play-pause', + 'play-next', + 'play-prev', + 'play-shuffle', + 'queue-open', + 'lyrics-toggle', + 'fullscreen-open', + 'sys-search-setting', + ]; + return priority.includes(c.id); + }), + 'group' + ); - if (results.length === 0) { - this.resultsContainer.innerHTML = - '
No results found
'; + this.renderGroups(groups); + } + + searchCommands(query) { + const fuseResults = this.fuse.search(query).slice(0, 12); + const matched = fuseResults.map((r) => r.item); + + if (matched.length === 0) { + this.renderGroups({}); return; } - results.forEach((result, index) => { - const div = document.createElement('div'); - div.className = `command-result-item ${index === this.selectedIndex ? 'selected' : ''}`; + const groups = this.groupBy(matched, 'group'); + this.renderGroups(groups); + } - const imgHtml = result.image - ? `` - : ''; + async searchMusic(query) { + if (!query || query.length < 2) return; - div.innerHTML = ` -
- ${imgHtml} -
${result.name}${result.description || ''}
-
- `; - div.addEventListener('click', async () => { - this.selectedIndex = index; - await this.executeSelected(); - }); - this.resultsContainer.appendChild(div); + const api = window.monochromeUi?.api; + if (!api) return; + + this.cancelMusicSearch(); + const controller = new AbortController(); + this.musicSearchAbort = controller; + + this.showMusicLoading(); + + try { + const [tracks, albums, artists] = await Promise.all([ + api.searchTracks(query, { limit: 4 }), + api.searchAlbums(query, { limit: 3 }), + api.searchArtists(query, { limit: 3 }), + ]); + + if (controller.signal.aborted || !this.isOpen) return; + + const musicGroups = {}; + + if (tracks?.items?.length) { + musicGroups['Tracks'] = tracks.items.map((track) => ({ + id: `track-${track.id}`, + group: 'Tracks', + icon: null, + image: api.getCoverUrl(track.album?.cover, 80), + label: track.title, + description: `${track.artist?.name || 'Unknown'} \u2022 ${track.album?.title || ''}`, + action: async () => { + window.monochromePlayer.setQueue([track], 0); + await window.monochromePlayer.playTrackFromQueue(); + }, + })); + } + + if (albums?.items?.length) { + musicGroups['Albums'] = albums.items.map((album) => ({ + id: `album-${album.id}`, + group: 'Albums', + icon: null, + image: api.getCoverUrl(album.cover, 80), + label: album.title, + description: album.artist?.name || 'Unknown', + action: () => { + navigate(`/album/${album.id}`); + }, + })); + } + + if (artists?.items?.length) { + musicGroups['Artists'] = artists.items.map((artist) => ({ + id: `artist-${artist.id}`, + group: 'Artists', + icon: null, + image: api.getArtistPictureUrl(artist.picture, 80), + label: artist.name, + description: 'Artist', + action: () => { + navigate(`/artist/${artist.id}`); + }, + })); + } + + if (Object.keys(musicGroups).length > 0) { + this.appendMusicGroups(musicGroups); + } + + this.removeMusicLoading(); + } catch (e) { + if (e.name !== 'AbortError') { + this.removeMusicLoading(); + } + } + } + + cancelMusicSearch() { + if (this.musicSearchAbort) { + this.musicSearchAbort.abort(); + this.musicSearchAbort = null; + } + } + + showMusicLoading() { + this.removeMusicLoading(); + const loading = document.createElement('div'); + loading.className = 'cmdk-loading'; + loading.setAttribute('data-music-loading', ''); + loading.innerHTML = '
Searching music...'; + this.resultsContainer.appendChild(loading); + } + + removeMusicLoading() { + this.resultsContainer.querySelector('[data-music-loading]')?.remove(); + } + + appendMusicGroups(musicGroups) { + this.removeMusicLoading(); + this.resultsContainer.querySelectorAll('[data-music-group]').forEach((el) => el.remove()); + + const startIndex = this.flatItems.length; + let index = startIndex; + + for (const [heading, items] of Object.entries(musicGroups)) { + const groupEl = document.createElement('div'); + groupEl.className = 'cmdk-group'; + groupEl.setAttribute('data-music-group', ''); + + const headingEl = document.createElement('div'); + headingEl.className = 'cmdk-group-heading'; + headingEl.textContent = heading; + groupEl.appendChild(headingEl); + + for (const item of items) { + const itemEl = this.createItemElement(item, index); + groupEl.appendChild(itemEl); + this.flatItems.push(item); + index++; + } + + this.resultsContainer.appendChild(groupEl); + } + } + + groupBy(items, key) { + const groups = {}; + for (const item of items) { + const group = item[key] || 'Other'; + if (!groups[group]) groups[group] = []; + groups[group].push(item); + } + return groups; + } + + renderGroups(groups) { + this.resultsContainer.innerHTML = ''; + this.flatItems = []; + let index = 0; + + const groupEntries = Object.entries(groups); + if (groupEntries.length === 0) { + const query = this.input.value.trim(); + if (query) { + const empty = document.createElement('div'); + empty.className = 'cmdk-empty'; + empty.textContent = 'No commands found'; + this.resultsContainer.appendChild(empty); + } + return; + } + + for (const [heading, items] of groupEntries) { + const groupEl = document.createElement('div'); + groupEl.className = 'cmdk-group'; + + const headingEl = document.createElement('div'); + headingEl.className = 'cmdk-group-heading'; + headingEl.textContent = heading; + groupEl.appendChild(headingEl); + + for (const item of items) { + const itemEl = this.createItemElement(item, index); + groupEl.appendChild(itemEl); + this.flatItems.push(item); + index++; + } + + this.resultsContainer.appendChild(groupEl); + } + + this.updateSelection(); + } + + createItemElement(item, index) { + const el = document.createElement('div'); + el.className = 'cmdk-item'; + el.setAttribute('data-index', index); + if (index === this.selectedIndex) el.setAttribute('data-selected', 'true'); + + let iconHtml = ''; + if (item.image) { + iconHtml = `
`; + } else if (item.icon && ICONS[item.icon]) { + iconHtml = `
${ICONS[item.icon]}
`; + } + + let shortcutHtml = ''; + if (item.shortcut) { + const keys = item.shortcut.split('+'); + shortcutHtml = `
${keys.map((k) => `${escapeHtml(k)}`).join('')}
`; + } + + const descHtml = item.description + ? `${escapeHtml(item.description)}` + : ''; + + el.innerHTML = `${iconHtml}
${escapeHtml(item.label)}${descHtml}
${shortcutHtml}`; + + el.addEventListener('click', () => { + this.selectedIndex = index; + this.executeSelected(); }); + + el.addEventListener('mouseenter', () => { + this.selectedIndex = index; + this.updateSelection(); + }); + + return el; } updateSelection() { - const items = this.resultsContainer.querySelectorAll('.command-result-item'); - items.forEach((item, index) => { - if (index === this.selectedIndex) { - item.classList.add('selected'); + const items = this.resultsContainer.querySelectorAll('.cmdk-item'); + items.forEach((item) => { + const idx = parseInt(item.getAttribute('data-index')); + if (idx === this.selectedIndex) { + item.setAttribute('data-selected', 'true'); item.scrollIntoView({ block: 'nearest' }); } else { - item.classList.remove('selected'); + item.removeAttribute('data-selected'); } }); } - async executeSelected() { - const result = this.results[this.selectedIndex]; - if (result && result.action) { - await result.action(); - if (result.type !== 'hint') { - this.close(); - } - } else if (result && result.type === 'command') { - this.input.value = `>${result.name} `; - await this.handleInput(); - } - } + executeSelected() { + const item = this.flatItems[this.selectedIndex]; + if (!item || !item.action) return; - 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); - } - - async 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()) { - await 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: async () => { - this.input.value = c.name; - await 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.`); + item.action(); this.close(); } - async downloadQueue(player, ui) { - const queue = player.getCurrentQueue(); - if (queue.length === 0) { - this.showNotification('Queue is empty.'); - return; - } + renderSettingsResults(query) { + if (this.allSettings.length === 0) this.cacheAllSettings(); - 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(); - } - - async handleNavigation(args) { - const validPages = ['home', 'library', 'recent', 'settings', 'unreleased', 'about', 'download']; - - if (!args || !args.trim()) { - await this.renderResults( - validPages.map((p) => ({ - name: `>go ${p}`, - description: `Navigate to ${p}`, - action: () => { - this.close(); - navigate(p === 'home' ? '/' : `/${p}`); - }, - type: 'command', - })) - ); - return; - } - - const page = args.trim().toLowerCase(); - - if (validPages.includes(page)) { - this.close(); - navigate(page === 'home' ? '/' : `/${page}`); - } else { - this.showNotification(`Unknown page: ${page}`); - } - } - - async handleSleepTimer(args) { - if (!args || !args.trim()) { - await 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`); - } - } - - async 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', + let results = this.allSettings; + if (query) { + const fuse = new Fuse(this.allSettings, { + keys: ['label', 'description'], + includeScore: true, + threshold: 0.4, + ignoreLocation: true, }); - await this.renderResults(results); - return; + results = fuse.search(query).map((r) => r.item); } - 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()); + const items = results.map((setting) => ({ + id: `setting-${setting.id}`, + group: `Settings \u2022 ${setting.tab}`, + icon: 'settings', + label: setting.label, + description: setting.description, + action: () => this.navigateToSetting(setting), + })); - 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()) { - await 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(); - } + const groups = this.groupBy(items, 'group'); + this.renderGroups(groups); } cacheAllSettings() { @@ -617,62 +1076,11 @@ class CommandPalette { : `setting-item-${Math.random().toString(36).substr(2, 9)}`; } - return { - id: item.id, - label, - description, - tab, - }; + return { id: item.id, label, description, tab }; }) .filter((s) => s.label); } - async handleSettingSearch(args, autoPick = false) { - const query = args.trim().toLowerCase(); - - if (!query) { - await 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; - } - - await this.renderResults( - results.map((setting) => ({ - name: setting.label, - description: `[${setting.tab}] ${setting.description}`, - action: () => { - this.navigateToSetting(setting); - this.close(); - }, - type: 'setting', - })) - ); - } - async navigateToSetting(setting) { navigate('/settings'); @@ -698,166 +1106,130 @@ class CommandPalette { } } - async performSearch(cmdName, query) { - if (!this.isOpen) return; + setTheme(theme) { + 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'); + }); + this.notify(`Theme set to ${theme}`); + } - const api = window.monochromeUi?.api; - if (!api) return; + async toggleVisualizer() { + const { visualizerSettings } = await import('./storage.js'); + const current = visualizerSettings.isEnabled(); + visualizerSettings.setEnabled(!current); + this.notify(`Visualizer ${!current ? 'enabled' : 'disabled'}`); - 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: async () => { - window.monochromePlayer.setQueue([track], 0); - await 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) { - await this.renderResults(results); + const overlay = document.getElementById('fullscreen-cover-overlay'); + if (overlay && getComputedStyle(overlay).display !== 'none') { + window.monochromeUi?.closeFullscreenCover(); } } - async handlePlay(args, autoPick) { - if (!args) return; + async setVisualizerPreset(preset) { + const { visualizerSettings } = await import('./storage.js'); + visualizerSettings.setPreset(preset); + if (window.monochromeUi?.visualizer) { + window.monochromeUi.visualizer.setPreset(preset); + } + this.notify(`Visualizer preset: ${preset}`); + } - 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); - await window.monochromePlayer.playTrackFromQueue(); - this.close(); - } + async setQuality(quality) { + const qualityNames = { LOW: 'Low', HIGH: 'High', LOSSLESS: 'Lossless', HI_RES_LOSSLESS: 'Hi-Res' }; + + if (window.monochromePlayer) { + window.monochromePlayer.setQuality(quality); + localStorage.setItem('playback-quality', quality); + const streamingSelect = document.getElementById('streaming-quality-setting'); + if (streamingSelect) streamingSelect.value = quality; + } + + const { downloadQualitySettings } = await import('./storage.js'); + downloadQualitySettings.setQuality(quality); + const downloadSelect = document.getElementById('download-quality-setting'); + if (downloadSelect) downloadSelect.value = quality; + + this.notify(`Quality set to ${qualityNames[quality] || quality}`); + } + + setSleepTimer(minutes) { + if (window.monochromePlayer) { + window.monochromePlayer.setSleepTimer(minutes); + this.notify(`Sleep timer: ${minutes} minutes`); } } - 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) { + async likeAllInQueue() { const player = window.monochromePlayer; - const api = window.monochromeUi.api; - let tracks = []; + const ui = window.monochromeUi; + if (!player || !ui) return; - 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); + const queue = player.getCurrentQueue(); + if (queue.length === 0) { + this.notify('Queue is empty'); + return; } + + const { handleTrackAction } = await import('./events.js'); + const scrobbler = window.monochromeScrobbler; + + let likedCount = 0; + this.notify('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.notify(`Liked ${likedCount} new track(s)`); + } + + async downloadQueue() { + const player = window.monochromePlayer; + const ui = window.monochromeUi; + if (!player || !ui) return; + + const queue = player.getCurrentQueue(); + if (queue.length === 0) { + this.notify('Queue is empty'); + return; + } + + const { downloadTracks } = await import('./downloads.js'); + const { downloadQualitySettings } = await import('./storage.js'); + downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), ui.lyricsManager); + } + + async createPlaylist() { + const name = `New Playlist ${new Date().toLocaleDateString()}`; + await db.createPlaylist(name); + navigate('/library'); + this.notify('Playlist created'); + } + + async createFolder() { + const name = `New Folder ${new Date().toLocaleDateString()}`; + await db.createFolder(name); + navigate('/library'); + this.notify('Folder created'); + } + + async clearCache() { + const api = window.monochromeUi?.api; + if (api) { + await api.clearCache(); + this.notify('Cache cleared'); + } + } + + async notify(message) { + const { showNotification } = await import('./downloads.js'); + showNotification(message); } } diff --git a/styles.css b/styles.css index c50347c..95d3844 100644 --- a/styles.css +++ b/styles.css @@ -8381,69 +8381,297 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #command-palette-overlay { position: fixed; inset: 0; - background: rgb(0, 0, 0, 0.5); - backdrop-filter: blur(4px); + background: rgb(0 0 0 / 60%); + backdrop-filter: blur(8px); z-index: 10000; display: flex; justify-content: center; align-items: flex-start; - padding-top: 10vh; - animation: fade-in 0.2s ease-out; + padding-top: min(10vh, 120px); + animation: cmdk-overlay-in 150ms ease-out; +} + +@keyframes cmdk-overlay-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes cmdk-scale-in { + from { + opacity: 0; + transform: scale(0.96); + } + + to { + opacity: 1; + transform: scale(1); + } } .command-palette { width: 100%; - max-width: 600px; + max-width: 640px; + margin: 0 1rem; background: var(--card); border: 1px solid var(--border); border-radius: 16px; - box-shadow: var(--shadow-2xl); + box-shadow: + 0 16px 70px rgb(0 0 0 / 50%), + 0 0 0 1px rgb(255 255 255 / 5%) inset; display: flex; flex-direction: column; overflow: hidden; - max-height: 60vh; + max-height: min(60vh, 480px); + animation: cmdk-scale-in 150ms ease-out; } .command-palette-header { - padding: 1.25rem 1.5rem; + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; border-bottom: 1px solid var(--border); } +.command-palette-search-icon { + flex-shrink: 0; + color: var(--muted-foreground); +} + #command-palette-input { - width: 100%; + flex: 1; + min-width: 0; background: transparent; border: none; - font-size: 1.2rem; - font-weight: 500; + font-size: 1rem; + font-weight: 400; color: var(--foreground); outline: none; } -.command-palette-results { - overflow-y: auto; - padding: 0.5rem; +#command-palette-input::placeholder { + color: var(--muted-foreground); } -.command-result-item { - padding: 0.75rem 1rem; - border-radius: var(--radius); - cursor: pointer; - display: flex; - justify-content: space-between; +.command-palette-kbd { + flex-shrink: 0; + display: inline-flex; align-items: center; - color: var(--foreground); + justify-content: center; + padding: 2px 6px; + font-size: 11px; + font-family: inherit; + font-weight: 500; + color: var(--muted-foreground); + background: var(--secondary); + border: 1px solid var(--border); + border-radius: 6px; + line-height: 1.4; } -.command-result-item:hover, -.command-result-item.selected { +.command-palette-results { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px 8px; +} + +.cmdk-group + .cmdk-group { + margin-top: 4px; +} + +.cmdk-group-heading { + padding: 6px 10px 4px; + font-size: 11px; + font-weight: 600; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.04em; + user-select: none; +} + +.cmdk-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius, 8px); + cursor: pointer; + font-size: 0.875rem; + color: var(--foreground); + transition: background 80ms ease; + user-select: none; + position: relative; +} + +.cmdk-item:hover { background: var(--secondary); } -.command-result-desc { - font-size: 0.85rem; +.cmdk-item[data-selected='true'] { + background: var(--secondary); +} + +.cmdk-item-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + min-height: 20px; color: var(--muted-foreground); } +.cmdk-item-icon img { + width: 28px; + height: 28px; + border-radius: 4px; + object-fit: cover; + aspect-ratio: 1 / 1; +} + +.cmdk-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.cmdk-item-label { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cmdk-item-description { + font-size: 0.75rem; + color: var(--muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cmdk-item-shortcut { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.cmdk-item-shortcut kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + font-size: 10px; + font-family: inherit; + font-weight: 500; + color: var(--muted-foreground); + background: var(--secondary); + border: 1px solid var(--border); + border-radius: 4px; + line-height: 1; +} + +.cmdk-empty { + display: flex; + align-items: center; + justify-content: center; + height: 64px; + color: var(--muted-foreground); + font-size: 0.875rem; +} + +.cmdk-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + color: var(--muted-foreground); + font-size: 0.8rem; +} + +.cmdk-loading-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--foreground); + border-radius: 50%; + animation: cmdk-spin 600ms linear infinite; +} + +@keyframes cmdk-spin { + to { + transform: rotate(360deg); + } +} + +.cmdk-separator { + height: 1px; + background: var(--border); + margin: 4px 8px; +} + +.command-palette-footer { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + border-top: 1px solid var(--border); + font-size: 0.7rem; + color: var(--muted-foreground); +} + +.command-palette-hint { + display: flex; + align-items: center; + gap: 4px; +} + +.command-palette-hint kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 4px; + font-size: 10px; + font-family: inherit; + font-weight: 500; + color: var(--muted-foreground); + background: var(--secondary); + border: 1px solid var(--border); + border-radius: 4px; +} + +@media (width <= 640px) { + #command-palette-overlay { + padding-top: 0; + align-items: flex-start; + } + + .command-palette { + max-width: 100%; + max-height: 100dvh; + margin: 0; + border-radius: 0; + border: none; + } + + .command-palette-footer { + display: none; + } +} + .video-card .card-image-container { aspect-ratio: 16 / 9 !important; margin-bottom: var(--spacing-sm); From d75f0e3196d318e2852e6987feed5379ce4799fb Mon Sep 17 00:00:00 2001 From: akane <107654710+genericness@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:53:16 -0700 Subject: [PATCH 2/4] refactor(ui): use icons.ts for command palette icons --- js/commandPalette.js | 162 +++++++++++++++++++++++++------------------ js/icons.ts | 31 +++++++++ 2 files changed, 125 insertions(+), 68 deletions(-) diff --git a/js/commandPalette.js b/js/commandPalette.js index 2fce066..5ea9dd5 100644 --- a/js/commandPalette.js +++ b/js/commandPalette.js @@ -2,65 +2,92 @@ import { debounce } from './utils.js'; import { db } from './db.js'; import Fuse from 'fuse.js'; import { navigate } from './router.js'; +import { + SVG_SEARCH, + SVG_HOUSE, + SVG_LIBRARY, + SVG_CLOCK, + SVG_CALENDAR, + SVG_SETTINGS, + SVG_INFO, + SVG_DOWNLOAD, + SVG_HAND_HEART, + SVG_PLAY, + SVG_SKIP_FORWARD, + SVG_SKIP_BACK, + SVG_SHUFFLE, + SVG_REPEAT, + SVG_MUTE, + SVG_VOLUME_1, + SVG_HEART, + SVG_LIST, + SVG_TRASH, + SVG_ALIGN_LEFT, + SVG_MAXIMIZE, + SVG_SPARKLES, + SVG_MONITOR, + SVG_MOON, + SVG_SUN, + SVG_PALETTE, + SVG_STORE, + SVG_SLIDERS, + SVG_PLUS, + SVG_FOLDER_PLUS, + SVG_KEYBOARD, + SVG_UPLOAD, + SVG_USER, + SVG_PENCIL, + SVG_LOG_OUT, + SVG_LOG_IN, + SVG_MUSIC, + SVG_DISC, + SVG_MIC, + SVG_RADIO, +} from './icons.js'; + +const ICON_SIZE = 16; const ICONS = { - search: '', - house: '', - library: - '', - clock: '', - calendar: - '', - settings: - '', - info: '', - download: - '', - heart: '', - play: '', - pause: '', - skipForward: - '', - skipBack: - '', - shuffle: - '', - repeat: '', - volumeX: - '', - volume: '', - list: '', - trash: '', - text: '', - maximize: - '', - sparkles: - '', - palette: - '', - sun: '', - moon: '', - sliders: - '', - plus: '', - folderPlus: - '', - user: '', - logOut: '', - logIn: '', - keyboard: - '', - music: '', - disc: '', - mic: '', - upload: '', - handHeart: - '', - monitor: - '', - pencil: '', - radio: '', - store: '', + search: SVG_SEARCH, + house: SVG_HOUSE, + library: SVG_LIBRARY, + clock: SVG_CLOCK, + calendar: SVG_CALENDAR, + settings: SVG_SETTINGS, + info: SVG_INFO, + download: SVG_DOWNLOAD, + handHeart: SVG_HAND_HEART, + play: SVG_PLAY, + skipForward: SVG_SKIP_FORWARD, + skipBack: SVG_SKIP_BACK, + shuffle: SVG_SHUFFLE, + repeat: SVG_REPEAT, + volumeX: SVG_MUTE, + volume: SVG_VOLUME_1, + heart: SVG_HEART, + list: SVG_LIST, + trash: SVG_TRASH, + text: SVG_ALIGN_LEFT, + maximize: SVG_MAXIMIZE, + sparkles: SVG_SPARKLES, + monitor: SVG_MONITOR, + moon: SVG_MOON, + sun: SVG_SUN, + palette: SVG_PALETTE, + store: SVG_STORE, + sliders: SVG_SLIDERS, + plus: SVG_PLUS, + folderPlus: SVG_FOLDER_PLUS, + keyboard: SVG_KEYBOARD, + upload: SVG_UPLOAD, + user: SVG_USER, + pencil: SVG_PENCIL, + logOut: SVG_LOG_OUT, + logIn: SVG_LOG_IN, + music: SVG_MUSIC, + disc: SVG_DISC, + mic: SVG_MIC, + radio: SVG_RADIO, }; function escapeHtml(str) { @@ -196,7 +223,7 @@ class CommandPalette { icon: 'skipForward', label: 'Next Track', keywords: ['next', 'skip', 'forward'], - shortcut: 'Shift+→', + shortcut: 'Shift+\u2192', action: () => { window.monochromePlayer?.playNext(); }, @@ -207,7 +234,7 @@ class CommandPalette { icon: 'skipBack', label: 'Previous Track', keywords: ['previous', 'back', 'rewind'], - shortcut: 'Shift+←', + shortcut: 'Shift+\u2190', action: () => { window.monochromePlayer?.playPrev(); }, @@ -252,7 +279,7 @@ class CommandPalette { icon: 'volume', label: 'Volume Up', keywords: ['volume', 'louder'], - shortcut: '↑', + shortcut: '\u2191', action: () => { const p = window.monochromePlayer; if (p) p.setVolume(p.userVolume + 0.1); @@ -264,7 +291,7 @@ class CommandPalette { icon: 'volume', label: 'Volume Down', keywords: ['volume', 'quieter', 'softer'], - shortcut: '↓', + shortcut: '\u2193', action: () => { const p = window.monochromePlayer; if (p) p.setVolume(p.userVolume - 0.1); @@ -478,7 +505,7 @@ class CommandPalette { id: 'theme-frappe', group: 'Theme', icon: 'palette', - label: 'Theme: Frappé', + label: 'Theme: Frapp\u00e9', keywords: ['theme', 'frappe', 'catppuccin'], action: () => this.setTheme('frappe'), }, @@ -823,7 +850,7 @@ class CommandPalette { musicGroups['Tracks'] = tracks.items.map((track) => ({ id: `track-${track.id}`, group: 'Tracks', - icon: null, + icon: 'music', image: api.getCoverUrl(track.album?.cover, 80), label: track.title, description: `${track.artist?.name || 'Unknown'} \u2022 ${track.album?.title || ''}`, @@ -838,7 +865,7 @@ class CommandPalette { musicGroups['Albums'] = albums.items.map((album) => ({ id: `album-${album.id}`, group: 'Albums', - icon: null, + icon: 'disc', image: api.getCoverUrl(album.cover, 80), label: album.title, description: album.artist?.name || 'Unknown', @@ -852,7 +879,7 @@ class CommandPalette { musicGroups['Artists'] = artists.items.map((artist) => ({ id: `artist-${artist.id}`, group: 'Artists', - icon: null, + icon: 'mic', image: api.getArtistPictureUrl(artist.picture, 80), label: artist.name, description: 'Artist', @@ -898,8 +925,7 @@ class CommandPalette { this.removeMusicLoading(); this.resultsContainer.querySelectorAll('[data-music-group]').forEach((el) => el.remove()); - const startIndex = this.flatItems.length; - let index = startIndex; + let index = this.flatItems.length; for (const [heading, items] of Object.entries(musicGroups)) { const groupEl = document.createElement('div'); @@ -981,7 +1007,7 @@ class CommandPalette { if (item.image) { iconHtml = `
`; } else if (item.icon && ICONS[item.icon]) { - iconHtml = `
${ICONS[item.icon]}
`; + iconHtml = `
${ICONS[item.icon](ICON_SIZE)}
`; } let shortcutHtml = ''; diff --git a/js/icons.ts b/js/icons.ts index 0c6d5e3..29ba9ee 100644 --- a/js/icons.ts +++ b/js/icons.ts @@ -1,45 +1,76 @@ +export { default as SVG_ALIGN_LEFT } from '!lucide/align-left.svg?svg&icon'; export { default as SVG_ANIMATE_SPIN } from '../images/animate-spin.svg?svg&icon'; export { default as SVG_APPLE } from '../images/apple.svg?svg&icon'; export { default as SVG_BIN } from '!lucide/trash-2.svg?svg&icon'; +export { default as SVG_CALENDAR } from '!lucide/calendar.svg?svg&icon'; export { default as SVG_CHECK } from '!lucide/check.svg?svg&icon'; export { default as SVG_CHECKBOX } from '!lucide/square.svg?svg&icon'; export { default as SVG_CHECKBOX_CHECKED } from '!lucide/check-square.svg?svg&icon'; export { default as SVG_CLOCK } from '!lucide/clock.svg?svg&icon'; export { default as SVG_CLOSE } from '!lucide/x.svg?svg&icon'; +export { default as SVG_DISC } from '!lucide/disc.svg?svg&icon'; export { default as SVG_DOWNLOAD } from '!lucide/download.svg?svg&icon'; export { default as SVG_EQUAL } from '!lucide/equal.svg?svg&icon'; export { default as SVG_FACEBOOK } from '../images/facebook.svg?svg&icon'; +export { default as SVG_FOLDER_PLUS } from '!lucide/folder-plus.svg?svg&icon'; export { default as SVG_GENIUS_ACTIVE } from '../images/genius-active.svg?svg&icon'; export { default as SVG_GENIUS_INACTIVE } from '../images/genius-inactive.svg?svg&icon'; export { default as SVG_GLOBE } from '!lucide/globe.svg?svg&icon'; +export { default as SVG_HAND_HEART } from '!lucide/hand-heart.svg?svg&icon'; export { default as SVG_HEART } from '!lucide/heart.svg?svg&icon&class=heart-icon'; export { default as SVG_HEART_FILLED } from '!lucide/heart.svg?svg&icon&class=heart-icon+filled'; +export { default as SVG_HOUSE } from '!lucide/house.svg?svg&icon'; +export { default as SVG_INFO } from '!lucide/info.svg?svg&icon'; export { default as SVG_INSTAGRAM } from '../images/instagram.svg?svg&icon'; +export { default as SVG_KEYBOARD } from '!lucide/keyboard.svg?svg&icon'; export { default as SVG_LEFT_ARROW } from '!lucide/chevron-left.svg?svg&icon'; +export { default as SVG_LIBRARY } from '!lucide/library.svg?svg&icon'; export { default as SVG_LINK } from '!lucide/link.svg?svg&icon'; +export { default as SVG_LIST } from '!lucide/list.svg?svg&icon'; +export { default as SVG_LOG_IN } from '!lucide/log-in.svg?svg&icon'; +export { default as SVG_LOG_OUT } from '!lucide/log-out.svg?svg&icon'; +export { default as SVG_MAXIMIZE } from '!lucide/maximize.svg?svg&icon'; export { default as SVG_MENU } from '!lucide/ellipsis-vertical.svg?svg&icon'; +export { default as SVG_MIC } from '!lucide/mic.svg?svg&icon'; export { default as SVG_MINUS } from '!lucide/minus.svg?svg&icon'; export { default as SVG_MIX } from '../images/mix.svg?svg&icon'; +export { default as SVG_MONITOR } from '!lucide/monitor.svg?svg&icon'; +export { default as SVG_MOON } from '!lucide/moon.svg?svg&icon'; export { default as SVG_MOVE_DOWN } from '!lucide/move-down.svg?svg&icon'; export { default as SVG_MOVE_UP } from '!lucide/move-up.svg?svg&icon'; +export { default as SVG_MUSIC } from '!lucide/music.svg?svg&icon'; export { default as SVG_MUTE } from '!lucide/volume-x.svg?svg&icon'; export { default as SVG_OFFLINE } from '!lucide/triangle-alert.svg?svg&icon'; +export { default as SVG_PALETTE } from '!lucide/palette.svg?svg&icon'; export { default as SVG_PAUSE } from '../images/pause.svg?svg&icon'; export { default as SVG_PAUSE_LARGE } from '../images/pause-large.svg?svg&icon'; +export { default as SVG_PENCIL } from '!lucide/pencil.svg?svg&icon'; export { default as SVG_PLAY } from '../images/play.svg?svg&icon'; export { default as SVG_PLAY_LARGE } from '../images/play-large.svg?svg&icon'; export { default as SVG_PLUS } from '!lucide/plus.svg?svg&icon'; +export { default as SVG_RADIO } from '!lucide/radio.svg?svg&icon'; export { default as SVG_REPEAT } from '!lucide/repeat.svg?svg&icon'; export { default as SVG_REPEAT_ONE } from '!lucide/repeat-1.svg?svg&icon'; export { default as SVG_RESET } from '!lucide/rotate-ccw.svg?svg&icon'; export { default as SVG_RIGHT_ARROW } from '!lucide/chevron-right.svg?svg&icon'; +export { default as SVG_SEARCH } from '!lucide/search.svg?svg&icon'; +export { default as SVG_SETTINGS } from '!lucide/settings.svg?svg&icon'; export { default as SVG_SHARE } from '!lucide/share.svg?svg&icon'; export { default as SVG_SHUFFLE } from '!lucide/shuffle.svg?svg&icon'; +export { default as SVG_SKIP_BACK } from '!lucide/skip-back.svg?svg&icon'; +export { default as SVG_SKIP_FORWARD } from '!lucide/skip-forward.svg?svg&icon'; +export { default as SVG_SLIDERS } from '!lucide/sliders-horizontal.svg?svg&icon'; export { default as SVG_SORT } from '../images/sort.svg?svg&icon'; export { default as SVG_SOUNDCLOUD } from '../images/soundcloud.svg?svg&icon'; +export { default as SVG_SPARKLES } from '!lucide/sparkles.svg?svg&icon'; export { default as SVG_SQUARE_PEN } from '!lucide/square-pen.svg?svg&icon'; +export { default as SVG_STORE } from '!lucide/store.svg?svg&icon'; +export { default as SVG_SUN } from '!lucide/sun.svg?svg&icon'; export { default as SVG_TRASH } from '!lucide/trash.svg?svg&icon'; export { default as SVG_TWITTER } from '../images/twitter.svg?svg&icon'; +export { default as SVG_UPLOAD } from '!lucide/upload.svg?svg&icon'; +export { default as SVG_USER } from '!lucide/user.svg?svg&icon'; export { default as SVG_VIDEO } from '!lucide/video.svg?svg&icon'; export { default as SVG_VOLUME } from '!lucide/volume-2.svg?svg&icon'; +export { default as SVG_VOLUME_1 } from '!lucide/volume-1.svg?svg&icon'; export { default as SVG_YOUTUBE } from '../images/youtube.svg?svg&icon'; From 7e56fc50301c441540941491ab4657976099d7fa Mon Sep 17 00:00:00 2001 From: akane <107654710+genericness@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:56:34 -0700 Subject: [PATCH 3/4] fix(ui): command palette accessibility, theme handling, and edge cases --- index.html | 7 ++++++- js/commandPalette.js | 31 ++++++++++++++++++++++++++----- styles.css | 1 + 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 4ef6d04..9af8e98 100644 --- a/index.html +++ b/index.html @@ -1505,10 +1505,15 @@ placeholder="Search commands, music, settings..." autocomplete="off" spellcheck="false" + aria-label="Command palette search" + role="combobox" + aria-expanded="true" + aria-controls="command-palette-results" + aria-autocomplete="list" /> ESC -
+