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 @@
+
+ ↑↓ navigate
+ ↵ select
+ esc close
+
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
-
+
↑↓ navigate
↵ select
diff --git a/js/commandPalette.js b/js/commandPalette.js
index 5ea9dd5..c99d204 100644
--- a/js/commandPalette.js
+++ b/js/commandPalette.js
@@ -642,6 +642,7 @@ class CommandPalette {
icon: 'search',
label: 'Search Settings...',
keywords: ['setting', 'find', 'search', 'preference', 'option', 'configure'],
+ keepOpen: true,
action: () => this.enterSettingsMode(),
},
@@ -923,6 +924,7 @@ class CommandPalette {
appendMusicGroups(musicGroups) {
this.removeMusicLoading();
+ this.resultsContainer.querySelector('.cmdk-empty')?.remove();
this.resultsContainer.querySelectorAll('[data-music-group]').forEach((el) => el.remove());
let index = this.flatItems.length;
@@ -1000,7 +1002,10 @@ class CommandPalette {
createItemElement(item, index) {
const el = document.createElement('div');
el.className = 'cmdk-item';
+ el.id = `cmdk-item-${index}`;
+ el.setAttribute('role', 'option');
el.setAttribute('data-index', index);
+ el.setAttribute('aria-selected', index === this.selectedIndex ? 'true' : 'false');
if (index === this.selectedIndex) el.setAttribute('data-selected', 'true');
let iconHtml = '';
@@ -1041,18 +1046,34 @@ class CommandPalette {
const idx = parseInt(item.getAttribute('data-index'));
if (idx === this.selectedIndex) {
item.setAttribute('data-selected', 'true');
+ item.setAttribute('aria-selected', 'true');
item.scrollIntoView({ block: 'nearest' });
} else {
item.removeAttribute('data-selected');
+ item.setAttribute('aria-selected', 'false');
}
});
+ this.input.setAttribute('aria-activedescendant', `cmdk-item-${this.selectedIndex}`);
}
- executeSelected() {
+ async executeSelected() {
const item = this.flatItems[this.selectedIndex];
if (!item || !item.action) return;
- item.action();
+ if (item.keepOpen) {
+ try {
+ await item.action();
+ } catch (e) {
+ console.error('Command palette action error:', e);
+ }
+ return;
+ }
+
+ try {
+ await item.action();
+ } catch (e) {
+ console.error('Command palette action error:', e);
+ }
this.close();
}
@@ -1132,9 +1153,9 @@ class CommandPalette {
}
}
- setTheme(theme) {
- document.documentElement.setAttribute('data-theme', theme);
- localStorage.setItem('theme', theme);
+ async setTheme(theme) {
+ const { themeManager } = await import('./storage.js');
+ themeManager.setTheme(theme);
const themeOptions = document.querySelectorAll('.theme-option');
themeOptions.forEach((opt) => {
if (opt.dataset.theme === theme) opt.classList.add('active');
diff --git a/styles.css b/styles.css
index 95d3844..9e96209 100644
--- a/styles.css
+++ b/styles.css
@@ -8527,6 +8527,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
color: var(--muted-foreground);
}
+/* stylelint-disable-next-line no-descending-specificity */
.cmdk-item-icon img {
width: 28px;
height: 28px;
From da5ade79a77fcb2e030cb0be6828e171ae5f5d02 Mon Sep 17 00:00:00 2001
From: akane <107654710+genericness@users.noreply.github.com>
Date: Sat, 21 Mar 2026 12:04:25 -0700
Subject: [PATCH 4/4] perf(ui): cache settings Fuse instance in command palette
---
js/commandPalette.js | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/js/commandPalette.js b/js/commandPalette.js
index c99d204..bbc088e 100644
--- a/js/commandPalette.js
+++ b/js/commandPalette.js
@@ -1082,13 +1082,7 @@ class CommandPalette {
let results = this.allSettings;
if (query) {
- const fuse = new Fuse(this.allSettings, {
- keys: ['label', 'description'],
- includeScore: true,
- threshold: 0.4,
- ignoreLocation: true,
- });
- results = fuse.search(query).map((r) => r.item);
+ results = this.settingsFuse.search(query).map((r) => r.item);
}
const items = results.map((setting) => ({
@@ -1126,6 +1120,13 @@ class CommandPalette {
return { id: item.id, label, description, tab };
})
.filter((s) => s.label);
+
+ this.settingsFuse = new Fuse(this.allSettings, {
+ keys: ['label', 'description'],
+ includeScore: true,
+ threshold: 0.4,
+ ignoreLocation: true,
+ });
}
async navigateToSetting(setting) {