kv-music/js/commandPalette.js

1284 lines
42 KiB
JavaScript

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: 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) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
class CommandPalette {
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.resultsContainer = document.getElementById('command-palette-results');
this.isOpen = false;
this.selectedIndex = 0;
this.flatItems = [];
this.allSettings = [];
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+\u2192',
action: () => {
window.monochromePlayer?.playNext();
},
},
{
id: 'play-prev',
group: 'Playback',
icon: 'skipBack',
label: 'Previous Track',
keywords: ['previous', 'back', 'rewind'],
shortcut: 'Shift+\u2190',
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: '\u2191',
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: '\u2193',
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\u00e9',
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'],
keepOpen: true,
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', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
});
this.input.addEventListener('input', () => this.handleInput());
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.cacheAllSettings();
}
toggle() {
if (this.isOpen) this.close();
else this.open();
}
open() {
this.isOpen = true;
this.settingsMode = false;
this.overlay.style.display = 'flex';
this.input.value = '';
this.input.placeholder = 'Search commands, music, settings...';
this.input.focus();
this.showDefaultCommands();
}
close() {
this.isOpen = false;
this.settingsMode = false;
this.overlay.style.display = 'none';
this.cancelMusicSearch();
}
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 (this.settingsMode) {
this.renderSettingsResults(query);
return;
}
if (!query) {
this.cancelMusicSearch();
this.showDefaultCommands();
return;
}
this.searchCommands(query);
this.debouncedMusicSearch(query);
}
handleKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.flatItems.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'Escape') {
if (this.settingsMode) {
this.settingsMode = false;
this.input.value = '';
this.input.placeholder = 'Search commands, music, settings...';
this.showDefaultCommands();
} else {
this.close();
}
} else if (e.key === 'Backspace' && this.settingsMode && !this.input.value) {
this.settingsMode = false;
this.input.placeholder = 'Search commands, music, settings...';
this.showDefaultCommands();
}
}
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'
);
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;
}
const groups = this.groupBy(matched, 'group');
this.renderGroups(groups);
}
async searchMusic(query) {
if (!query || query.length < 2) return;
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: 'music',
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: 'disc',
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: 'mic',
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 = '<div class="cmdk-loading-spinner"></div>Searching music...';
this.resultsContainer.appendChild(loading);
}
removeMusicLoading() {
this.resultsContainer.querySelector('[data-music-loading]')?.remove();
}
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;
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.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 = '';
if (item.image) {
iconHtml = `<div class="cmdk-item-icon"><img src="${escapeHtml(item.image)}" crossorigin="anonymous" alt="" loading="lazy" /></div>`;
} else if (item.icon && ICONS[item.icon]) {
iconHtml = `<div class="cmdk-item-icon">${ICONS[item.icon](ICON_SIZE)}</div>`;
}
let shortcutHtml = '';
if (item.shortcut) {
const keys = item.shortcut.split('+');
shortcutHtml = `<div class="cmdk-item-shortcut">${keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join('')}</div>`;
}
const descHtml = item.description
? `<span class="cmdk-item-description">${escapeHtml(item.description)}</span>`
: '';
el.innerHTML = `${iconHtml}<div class="cmdk-item-content"><span class="cmdk-item-label">${escapeHtml(item.label)}</span>${descHtml}</div>${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('.cmdk-item');
items.forEach((item) => {
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}`);
}
async executeSelected() {
const item = this.flatItems[this.selectedIndex];
if (!item || !item.action) return;
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();
}
renderSettingsResults(query) {
if (this.allSettings.length === 0) this.cacheAllSettings();
let results = this.allSettings;
if (query) {
results = this.settingsFuse.search(query).map((r) => r.item);
}
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),
}));
const groups = this.groupBy(items, 'group');
this.renderGroups(groups);
}
cacheAllSettings() {
const settingItems = document.querySelectorAll('#page-settings .setting-item');
this.allSettings = Array.from(settingItems)
.map((item) => {
const labelEl = item.querySelector('.label');
const descEl = item.querySelector('.description');
const tabEl = item.closest('.settings-tab-content');
const label = labelEl ? labelEl.textContent.trim() : '';
const description = descEl ? descEl.textContent.trim() : '';
const tab = tabEl ? tabEl.id.replace('settings-tab-', '') : '';
if (!item.id) {
const inputEl = item.querySelector('input[id], select[id], button[id]');
item.id = inputEl
? `setting-item-for-${inputEl.id}`
: `setting-item-${Math.random().toString(36).substr(2, 9)}`;
}
return { id: item.id, label, description, tab };
})
.filter((s) => s.label);
this.settingsFuse = new Fuse(this.allSettings, {
keys: ['label', 'description'],
includeScore: true,
threshold: 0.4,
ignoreLocation: true,
});
}
async navigateToSetting(setting) {
navigate('/settings');
await new Promise((resolve) => setTimeout(resolve, 100));
const tabButton = document.querySelector(`.settings-tab[data-tab="${setting.tab}"]`);
if (tabButton && !tabButton.classList.contains('active')) {
tabButton.click();
}
await new Promise((resolve) => setTimeout(resolve, 50));
const settingElement = document.getElementById(setting.id);
if (settingElement) {
settingElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
settingElement.style.transition = 'background-color 0.3s ease-out, box-shadow 0.3s ease-out';
settingElement.style.backgroundColor = 'rgba(var(--highlight-rgb), 0.2)';
settingElement.style.boxShadow = '0 0 0 2px rgba(var(--highlight-rgb), 0.5)';
setTimeout(() => {
settingElement.style.backgroundColor = '';
settingElement.style.boxShadow = '';
}, 2000);
}
}
async 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');
else opt.classList.remove('active');
});
this.notify(`Theme set to ${theme}`);
}
async toggleVisualizer() {
const { visualizerSettings } = await import('./storage.js');
const current = visualizerSettings.isEnabled();
visualizerSettings.setEnabled(!current);
this.notify(`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.notify(`Visualizer preset: ${preset}`);
}
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 likeAllInQueue() {
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 { 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);
}
}
new CommandPalette();