1307 lines
43 KiB
JavaScript
1307 lines
43 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';
|
|
import { Player } from './player.js';
|
|
import { UIRenderer } from './ui.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: () => {
|
|
Player.instance.handlePlayPause();
|
|
},
|
|
},
|
|
{
|
|
id: 'play-next',
|
|
group: 'Playback',
|
|
icon: 'skipForward',
|
|
label: 'Next Track',
|
|
keywords: ['next', 'skip', 'forward'],
|
|
shortcut: 'Shift+\u2192',
|
|
action: () => {
|
|
Player.instance.playNext();
|
|
},
|
|
},
|
|
{
|
|
id: 'play-prev',
|
|
group: 'Playback',
|
|
icon: 'skipBack',
|
|
label: 'Previous Track',
|
|
keywords: ['previous', 'back', 'rewind'],
|
|
shortcut: 'Shift+\u2190',
|
|
action: () => {
|
|
Player.instance.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 = Player.instance.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 = Player.instance;
|
|
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 = Player.instance;
|
|
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: async () => {
|
|
Player.instance.wipeQueue();
|
|
await 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-auto',
|
|
group: 'Audio',
|
|
icon: 'sliders',
|
|
label: 'Quality: Auto (Adaptive)',
|
|
keywords: ['quality', 'auto', 'adaptive', 'streaming', 'bitrate'],
|
|
action: () => this.setQuality('auto'),
|
|
},
|
|
{
|
|
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-lossless',
|
|
group: 'Audio',
|
|
icon: 'sliders',
|
|
label: 'Quality: Lossless',
|
|
keywords: ['quality', 'lossless', 'flac', 'streaming'],
|
|
action: () => this.setQuality('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');
|
|
await 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().catch(console.error);
|
|
} 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 = UIRenderer.instance.api;
|
|
if (!api) return;
|
|
|
|
this.cancelMusicSearch();
|
|
const controller = new AbortController();
|
|
this.musicSearchAbort = controller;
|
|
|
|
this.showMusicLoading();
|
|
|
|
try {
|
|
const results = await api.search(query, { limit: 4 });
|
|
const tracks = results.tracks || { items: [] };
|
|
const albums = results.albums || { items: [] };
|
|
const artists = results.artists || { items: [] };
|
|
|
|
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 () => {
|
|
Player.instance.setQueue([track], 0);
|
|
await Player.instance.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', async () => {
|
|
this.selectedIndex = index;
|
|
await 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');
|
|
});
|
|
await this.notify(`Theme set to ${theme}`);
|
|
}
|
|
|
|
async toggleVisualizer() {
|
|
const { visualizerSettings } = await import('./storage.js');
|
|
const current = visualizerSettings.isEnabled();
|
|
visualizerSettings.setEnabled(!current);
|
|
await this.notify(`Visualizer ${!current ? 'enabled' : 'disabled'}`);
|
|
|
|
const overlay = document.getElementById('fullscreen-cover-overlay');
|
|
if (overlay && getComputedStyle(overlay).display !== 'none') {
|
|
UIRenderer.instance.closeFullscreenCover();
|
|
}
|
|
}
|
|
|
|
async setVisualizerPreset(preset) {
|
|
const { visualizerSettings } = await import('./storage.js');
|
|
visualizerSettings.setPreset(preset);
|
|
if (UIRenderer.instance.visualizer) {
|
|
UIRenderer.instance.visualizer.setPreset(preset);
|
|
}
|
|
await this.notify(`Visualizer preset: ${preset}`);
|
|
}
|
|
|
|
async setQuality(quality) {
|
|
const qualityNames = {
|
|
auto: 'Auto',
|
|
LOW: 'Low',
|
|
HIGH: 'High',
|
|
LOSSLESS: 'Lossless',
|
|
HI_RES_LOSSLESS: 'Hi-Res',
|
|
};
|
|
|
|
if (Player.instance) {
|
|
// Set fallback API quality (Auto maps back to Hi-Res)
|
|
const apiQuality = quality === 'auto' ? 'LOSSLESS' : quality;
|
|
Player.instance.setQuality(apiQuality);
|
|
localStorage.setItem('playback-quality', apiQuality);
|
|
|
|
// Set adaptive streaming quality
|
|
localStorage.setItem('adaptive-playback-quality', quality);
|
|
if (Player.instance.forceQuality) Player.instance.forceQuality(quality);
|
|
|
|
const streamingSelect = document.getElementById('streaming-quality-setting');
|
|
if (streamingSelect) streamingSelect.value = quality;
|
|
}
|
|
|
|
const { downloadQualitySettings } = await import('./storage.js');
|
|
// Do not pass auto to download quality, resolve it to original fallback
|
|
const dlQuality = quality === 'auto' ? 'LOSSLESS' : quality;
|
|
downloadQualitySettings.setQuality(dlQuality);
|
|
const downloadSelect = document.getElementById('download-quality-setting');
|
|
if (downloadSelect) downloadSelect.value = dlQuality;
|
|
|
|
await this.notify(`Quality set to ${qualityNames[quality] || quality}`);
|
|
}
|
|
|
|
async setSleepTimer(minutes) {
|
|
if (Player.instance) {
|
|
Player.instance.setSleepTimer(minutes);
|
|
await this.notify(`Sleep timer: ${minutes} minutes`);
|
|
}
|
|
}
|
|
|
|
async likeAllInQueue() {
|
|
const player = Player.instance;
|
|
const ui = UIRenderer.instance;
|
|
if (!player || !ui) return;
|
|
|
|
const queue = player.getCurrentQueue();
|
|
if (queue.length === 0) {
|
|
await this.notify('Queue is empty');
|
|
return;
|
|
}
|
|
|
|
const { handleTrackAction } = await import('./events.js');
|
|
const scrobbler = window.monochromeScrobbler;
|
|
|
|
let likedCount = 0;
|
|
await 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++;
|
|
}
|
|
}
|
|
await this.notify(`Liked ${likedCount} new track(s)`);
|
|
}
|
|
|
|
async downloadQueue() {
|
|
const player = Player.instance;
|
|
const ui = UIRenderer.instance;
|
|
if (!player || !ui) return;
|
|
|
|
const queue = player.getCurrentQueue();
|
|
if (queue.length === 0) {
|
|
await this.notify('Queue is empty');
|
|
return;
|
|
}
|
|
|
|
const { downloadTracks } = await import('./downloads.js');
|
|
const { downloadQualitySettings } = await import('./storage.js');
|
|
await downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), ui.lyricsManager);
|
|
}
|
|
|
|
async createPlaylist() {
|
|
const name = `New Playlist ${new Date().toLocaleDateString()}`;
|
|
await db.createPlaylist(name);
|
|
navigate('/library');
|
|
await this.notify('Playlist created');
|
|
}
|
|
|
|
async createFolder() {
|
|
const name = `New Folder ${new Date().toLocaleDateString()}`;
|
|
await db.createFolder(name);
|
|
navigate('/library');
|
|
await this.notify('Folder created');
|
|
}
|
|
|
|
async clearCache() {
|
|
const api = UIRenderer.instance.api;
|
|
if (api) {
|
|
await api.clearCache();
|
|
await this.notify('Cache cleared');
|
|
}
|
|
}
|
|
|
|
async notify(message) {
|
|
await import('./downloads.js').then((m) => m.showNotification(message)).catch(console.error);
|
|
}
|
|
}
|
|
|
|
new CommandPalette();
|