feat(keyboard): command palette
This commit is contained in:
parent
af45d86a2a
commit
0b2fe88731
6 changed files with 918 additions and 4 deletions
|
|
@ -89,7 +89,7 @@
|
|||
|
||||
### Power User Features
|
||||
|
||||
- Keyboard shortcuts for power users
|
||||
- Keyboard shortcuts & Command Palette (CTRL+K) for power users
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
14
index.html
14
index.html
|
|
@ -1719,6 +1719,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="command-palette-overlay" style="display: none">
|
||||
<div class="command-palette">
|
||||
<div class="command-palette-header">
|
||||
<input type="text" id="command-palette-input" placeholder="Type > to start a command..." autocomplete="off" />
|
||||
</div>
|
||||
<div id="command-palette-results" class="command-palette-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-overlay"></div>
|
||||
|
||||
<div id="csv-import-progress" class="csv-import-progress" style="display: none">
|
||||
|
|
@ -1747,7 +1756,7 @@
|
|||
<aside class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-logo">
|
||||
<a href="/" class="sidebar-logo-link">
|
||||
<a href="https://monochrome.tf/" class="sidebar-logo-link">
|
||||
<svg
|
||||
class="app-logo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -2332,7 +2341,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="home-view-explore" class="home-view" style="display: none">
|
||||
<div id="home-view-explore" class="home-view" style="display: none;">
|
||||
<div id="explore-content">
|
||||
<div class="card-grid" id="explore-grid"></div>
|
||||
</div>
|
||||
|
|
@ -3675,7 +3684,6 @@
|
|||
<option value="particles">Particles</option>
|
||||
<option value="unknown-pleasures">Unknown Pleasures</option>
|
||||
<option value="butterchurn">Butterchurn (Milkdrop)</option>
|
||||
<option value="kawarp">Kawarp (Album Art)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item" id="visualizer-mode-setting">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { registerSW } from 'virtual:pwa-register';
|
|||
import './smooth-scrolling.js';
|
||||
import { openEditProfile } from './profile.js';
|
||||
import { ThemeStore } from './themeStore.js';
|
||||
import './commandPalette.js';
|
||||
|
||||
import { initTracker } from './tracker.js';
|
||||
import {
|
||||
|
|
@ -421,7 +422,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const ui = new UIRenderer(api, player);
|
||||
window.monochromeUi = ui;
|
||||
const scrobbler = new MultiScrobbler();
|
||||
window.monochromeScrobbler = scrobbler;
|
||||
const lyricsManager = new LyricsManager(api);
|
||||
ui.lyricsManager = lyricsManager;
|
||||
|
||||
// Check browser support for local files
|
||||
const selectLocalBtn = document.getElementById('select-local-folder-btn');
|
||||
|
|
|
|||
820
js/commandPalette.js
Normal file
820
js/commandPalette.js
Normal file
|
|
@ -0,0 +1,820 @@
|
|||
import { debounce } from './utils.js';
|
||||
import { db } from './db.js';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
class CommandPalette {
|
||||
constructor() {
|
||||
this.overlay = document.getElementById('command-palette-overlay');
|
||||
this.input = document.getElementById('command-palette-input');
|
||||
this.resultsContainer = document.getElementById('command-palette-results');
|
||||
this.isOpen = false;
|
||||
this.selectedIndex = 0;
|
||||
this.results = [];
|
||||
|
||||
this.allSettings = [];
|
||||
this.debouncedSearch = debounce(this.performSearch.bind(this), 300);
|
||||
|
||||
this.commands = [
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'Change theme (white, dark, ocean, purple, forest, etc.)',
|
||||
action: (args) => this.handleTheme(args)
|
||||
},
|
||||
{
|
||||
name: 'play',
|
||||
description: 'Search and play a track',
|
||||
action: (args, autoPick) => this.handlePlay(args, autoPick)
|
||||
},
|
||||
{
|
||||
name: 'shuffle',
|
||||
description: 'Shuffle a playlist, artist, or album',
|
||||
action: (args, autoPick) => this.handleShuffle(args, autoPick)
|
||||
},
|
||||
{
|
||||
name: 'queue',
|
||||
description: 'Manage the queue (wipe, like all, download)',
|
||||
action: (args) => this.handleQueue(args)
|
||||
},
|
||||
{
|
||||
name: 'setting',
|
||||
description: 'Search for a specific setting',
|
||||
action: (args) => this.handleSettingSearch(args)
|
||||
},
|
||||
{
|
||||
name: 'sleep',
|
||||
description: 'Set sleep timer in minutes',
|
||||
action: (args) => this.handleSleepTimer(args)
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
description: 'Set streaming & download quality',
|
||||
action: (args) => this.handleQuality(args)
|
||||
},
|
||||
{
|
||||
name: 'visualizer',
|
||||
description: 'Control visualizer (toggle, preset)',
|
||||
action: (args) => this.handleVisualizer(args)
|
||||
},
|
||||
{
|
||||
name: 'cache',
|
||||
description: 'Clear application cache',
|
||||
action: () => this.handleClearCache()
|
||||
}
|
||||
];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
this.input.addEventListener('input', () => this.handleInput());
|
||||
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.close();
|
||||
});
|
||||
|
||||
this.cacheAllSettings();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) this.close();
|
||||
else this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.overlay.style.display = 'flex';
|
||||
this.input.value = '>';
|
||||
this.input.focus();
|
||||
this.handleInput();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
handleInput() {
|
||||
const value = this.input.value;
|
||||
this.selectedIndex = 0;
|
||||
|
||||
if (!value.startsWith('>')) {
|
||||
this.renderResults([{
|
||||
name: 'Type > to use commands',
|
||||
description: 'e.g. >theme White, >play The Whole World Is Free',
|
||||
action: () => { this.input.value = '>'; this.handleInput(); },
|
||||
type: 'hint'
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullQuery = value.slice(1);
|
||||
const match = fullQuery.match(/^(\S+)(?:\s+(.*))?$/);
|
||||
|
||||
if (!match) {
|
||||
this.renderDefaultCommands();
|
||||
return;
|
||||
}
|
||||
|
||||
const cmdName = match[1].toLowerCase();
|
||||
const args = match[2] || '';
|
||||
|
||||
const command = this.commands.find(c => c.name === cmdName);
|
||||
|
||||
if (command) {
|
||||
const commandsWithSubmenus = ['queue', 'go', 'visualizer', 'quality', 'sleep', 'setting'];
|
||||
if (commandsWithSubmenus.includes(command.name) && !args.trim()) {
|
||||
command.action(args);
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderResults([{
|
||||
name: `Execute: ${command.name} ${args}`,
|
||||
description: args ? `Run ${command.name} for "${args}"` : command.description,
|
||||
action: () => command.action(args, ['play', 'shuffle', 'setting'].includes(command.name)),
|
||||
type: 'execution'
|
||||
}]);
|
||||
|
||||
if (args.trim().length > 0 && (cmdName === 'play' || cmdName === 'shuffle')) {
|
||||
this.debouncedSearch(cmdName, args.trim());
|
||||
}
|
||||
} else {
|
||||
this.renderDefaultCommands(cmdName);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
|
||||
this.updateSelection();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.updateSelection();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeSelected();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
renderDefaultCommands(filter = '') {
|
||||
let cmds = this.commands;
|
||||
if (filter) {
|
||||
if (Fuse) {
|
||||
const fuse = new Fuse(this.commands, { keys: ['name', 'description'] });
|
||||
cmds = fuse.search(filter).map(r => r.item);
|
||||
} else {
|
||||
cmds = this.commands.filter(c => c.name.includes(filter));
|
||||
}
|
||||
}
|
||||
|
||||
this.renderResults(cmds.map(c => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
action: () => {
|
||||
this.input.value = `>${c.name} `;
|
||||
this.handleInput();
|
||||
},
|
||||
type: 'command'
|
||||
})));
|
||||
}
|
||||
|
||||
renderResults(results) {
|
||||
this.results = results;
|
||||
this.resultsContainer.innerHTML = '';
|
||||
|
||||
if (results.length === 0) {
|
||||
this.resultsContainer.innerHTML = '<div style="padding: 1rem; color: var(--muted-foreground); text-align: center;">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = `command-result-item ${index === this.selectedIndex ? 'selected' : ''}`;
|
||||
|
||||
const imgHtml = result.image ? `<img src="${result.image}" crossorigin="anonymous" style="width: 32px; height: 32px; border-radius: 4px; margin-right: 10px; object-fit: cover;">` : '';
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="display: flex; align-items: center;">
|
||||
${imgHtml}
|
||||
<div style="display: flex; flex-direction: column;"><span class="command-result-name" style="font-weight: 500;">${result.name}</span><span class="command-result-desc" style="font-size: 0.8rem; opacity: 0.7;">${result.description || ''}</span></div>
|
||||
</div>
|
||||
`;
|
||||
div.addEventListener('click', () => {
|
||||
this.selectedIndex = index;
|
||||
this.executeSelected();
|
||||
});
|
||||
this.resultsContainer.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
const items = this.resultsContainer.querySelectorAll('.command-result-item');
|
||||
items.forEach((item, index) => {
|
||||
if (index === this.selectedIndex) {
|
||||
item.classList.add('selected');
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
executeSelected() {
|
||||
const result = this.results[this.selectedIndex];
|
||||
if (result && result.action) {
|
||||
result.action();
|
||||
if (result.type !== 'hint') {
|
||||
this.close();
|
||||
}
|
||||
} else if (result && result.type === 'command') {
|
||||
this.input.value = `>${result.name} `;
|
||||
this.handleInput();
|
||||
}
|
||||
}
|
||||
|
||||
handleTheme(args) {
|
||||
if (!args) return;
|
||||
const theme = args.trim().toLowerCase();
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const themeOptions = document.querySelectorAll('.theme-option');
|
||||
themeOptions.forEach(opt => {
|
||||
if (opt.dataset.theme === theme) opt.classList.add('active');
|
||||
else opt.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
async showNotification(message) {
|
||||
const { showNotification } = await import('./downloads.js');
|
||||
showNotification(message);
|
||||
}
|
||||
|
||||
handleQueue(args) {
|
||||
const player = window.monochromePlayer;
|
||||
const ui = window.monochromeUi;
|
||||
|
||||
if (!player || !ui) {
|
||||
console.error('Player or UI not available for queue command');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args || !args.trim()) {
|
||||
this.renderResults([
|
||||
{ name: '>queue wipe', description: 'Clear the queue and stop playback' },
|
||||
{ name: '>queue like all', description: 'Like all tracks in the current queue' },
|
||||
{ name: '>queue download', description: 'Download all tracks in the current queue' },
|
||||
].map(c => ({
|
||||
...c,
|
||||
type: 'command',
|
||||
action: () => {
|
||||
this.input.value = c.name;
|
||||
this.handleInput();
|
||||
}
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const subCommand = args.trim().toLowerCase();
|
||||
|
||||
switch (subCommand) {
|
||||
case 'wipe':
|
||||
player.wipeQueue();
|
||||
this.showNotification('Queue wiped.');
|
||||
this.close();
|
||||
break;
|
||||
case 'like all':
|
||||
this.likeAllInQueue(player, ui);
|
||||
break;
|
||||
case 'download':
|
||||
this.downloadQueue(player, ui);
|
||||
break;
|
||||
default:
|
||||
this.showNotification(`Unknown queue command: ${subCommand}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async likeAllInQueue(player, ui) {
|
||||
const queue = player.getCurrentQueue();
|
||||
if (queue.length === 0) {
|
||||
this.showNotification('Queue is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { handleTrackAction } = await import('./events.js');
|
||||
const scrobbler = window.monochromeScrobbler;
|
||||
|
||||
let likedCount = 0;
|
||||
this.showNotification('Liking all tracks in queue...');
|
||||
for (const track of queue) {
|
||||
const isLiked = await db.isFavorite('track', track.id);
|
||||
if (!isLiked) {
|
||||
await handleTrackAction('toggle-like', track, player, ui.api, ui.lyricsManager, 'track', ui, scrobbler);
|
||||
likedCount++;
|
||||
}
|
||||
}
|
||||
this.showNotification(`Liked ${likedCount} new track(s) in the queue.`);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async downloadQueue(player, ui) {
|
||||
const queue = player.getCurrentQueue();
|
||||
if (queue.length === 0) {
|
||||
this.showNotification('Queue is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { downloadTracks } = await import('./downloads.js');
|
||||
const { downloadQualitySettings } = await import('./storage.js');
|
||||
const lyricsManager = ui.lyricsManager;
|
||||
|
||||
downloadTracks(queue, ui.api, downloadQualitySettings.getQuality(), lyricsManager);
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleNavigation(args) {
|
||||
const validPages = ['home', 'library', 'recent', 'settings', 'unreleased', 'about', 'download'];
|
||||
|
||||
if (!args || !args.trim()) {
|
||||
this.renderResults(validPages.map(p => ({
|
||||
name: `>go ${p}`,
|
||||
description: `Navigate to ${p}`,
|
||||
action: () => {
|
||||
this.close();
|
||||
import('./router.js').then(m => m.navigate(p === 'home' ? '/' : `/${p}`));
|
||||
},
|
||||
type: 'command'
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const page = args.trim().toLowerCase();
|
||||
|
||||
if (validPages.includes(page)) {
|
||||
this.close();
|
||||
import('./router.js').then(m => m.navigate(page === 'home' ? '/' : `/${page}`));
|
||||
} else {
|
||||
this.showNotification(`Unknown page: ${page}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleSleepTimer(args) {
|
||||
if (!args || !args.trim()) {
|
||||
this.renderResults([15, 30, 45, 60, 120].map(m => ({
|
||||
name: `>sleep ${m}`,
|
||||
description: `Set sleep timer for ${m} minutes`,
|
||||
action: () => {
|
||||
this.setSleepTimer(m);
|
||||
this.close();
|
||||
},
|
||||
type: 'command'
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = parseInt(args.trim());
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
this.setSleepTimer(minutes);
|
||||
this.close();
|
||||
} else {
|
||||
this.showNotification("Invalid duration");
|
||||
}
|
||||
}
|
||||
|
||||
setSleepTimer(minutes) {
|
||||
if (window.monochromePlayer) {
|
||||
window.monochromePlayer.setSleepTimer(minutes);
|
||||
this.showNotification(`Sleep timer set for ${minutes} minutes`);
|
||||
}
|
||||
}
|
||||
|
||||
handleQuality(args) {
|
||||
const qualityMap = {
|
||||
'low': 'LOW',
|
||||
'high': 'HIGH',
|
||||
'lossless': 'LOSSLESS',
|
||||
'hires': 'HI_RES_LOSSLESS',
|
||||
'hi-res': 'HI_RES_LOSSLESS',
|
||||
'master': 'HI_RES_LOSSLESS'
|
||||
};
|
||||
|
||||
const displayQualities = [
|
||||
{ id: 'low', name: 'Low', code: 'LOW' },
|
||||
{ id: 'high', name: 'High', code: 'HIGH' },
|
||||
{ id: 'lossless', name: 'Lossless', code: 'LOSSLESS' },
|
||||
{ id: 'hi-res', name: 'Hi-Res', code: 'HI_RES_LOSSLESS' }
|
||||
];
|
||||
|
||||
if (!args || !args.trim()) {
|
||||
const results = displayQualities.map(q => ({
|
||||
name: `>quality ${q.id}`,
|
||||
description: `Set quality to ${q.name}`,
|
||||
action: () => {
|
||||
this.setQuality(q.code, true, true);
|
||||
this.close();
|
||||
},
|
||||
type: 'command'
|
||||
}));
|
||||
|
||||
results.push({
|
||||
name: 'Usage: >quality [level] [-S] [-D]',
|
||||
description: '-S for Streaming only, -D for Download only',
|
||||
action: () => {},
|
||||
type: 'hint'
|
||||
});
|
||||
this.renderResults(results);
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const qualityKey = parts.find(p => !p.startsWith('-'))?.toLowerCase();
|
||||
const flags = parts.filter(p => p.startsWith('-')).map(f => f.toLowerCase());
|
||||
|
||||
if (!qualityKey || !qualityMap[qualityKey]) {
|
||||
this.showNotification('Invalid quality setting');
|
||||
return;
|
||||
}
|
||||
|
||||
const qualityCode = qualityMap[qualityKey];
|
||||
let setStreaming = true;
|
||||
let setDownload = true;
|
||||
|
||||
if (flags.includes('-d') && !flags.includes('-s')) {
|
||||
setStreaming = false;
|
||||
} else if (flags.includes('-s') && !flags.includes('-d')) {
|
||||
setDownload = false;
|
||||
}
|
||||
|
||||
this.setQuality(qualityCode, setStreaming, setDownload);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async setQuality(quality, setStreaming, setDownload) {
|
||||
const messages = [];
|
||||
const qualityName = this.getQualityName(quality);
|
||||
|
||||
if (setStreaming) {
|
||||
if (window.monochromePlayer) {
|
||||
window.monochromePlayer.setQuality(quality);
|
||||
localStorage.setItem('playback-quality', quality);
|
||||
messages.push('Streaming');
|
||||
|
||||
const streamingSelect = document.getElementById('streaming-quality-setting');
|
||||
if (streamingSelect) {
|
||||
streamingSelect.value = quality;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (setDownload) {
|
||||
const { downloadQualitySettings } = await import('./storage.js');
|
||||
downloadQualitySettings.setQuality(quality);
|
||||
messages.push('Download');
|
||||
|
||||
const downloadSelect = document.getElementById('download-quality-setting');
|
||||
if (downloadSelect) {
|
||||
downloadSelect.value = quality;
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
this.showNotification(`${messages.join(' & ')} quality set to ${qualityName}`);
|
||||
}
|
||||
}
|
||||
|
||||
getQualityName(code) {
|
||||
const names = {
|
||||
'LOW': 'Low',
|
||||
'HIGH': 'High',
|
||||
'LOSSLESS': 'Lossless',
|
||||
'HI_RES_LOSSLESS': 'Hi-Res'
|
||||
};
|
||||
return names[code] || code;
|
||||
}
|
||||
|
||||
async handleVisualizer(args) {
|
||||
if (!args || !args.trim()) {
|
||||
this.renderResults([
|
||||
{ name: '>visualizer toggle', description: 'Toggle visualizer on/off', cmd: 'toggle' },
|
||||
{ name: '>visualizer butterchurn', description: 'Set preset to Butterchurn', cmd: 'butterchurn' },
|
||||
{ name: '>visualizer kawarp', description: 'Set preset to Kawarp', cmd: 'kawarp' },
|
||||
{ name: '>visualizer lcd', description: 'Set preset to LCD', cmd: 'lcd' },
|
||||
{ name: '>visualizer particles', description: 'Set preset to Particles', cmd: 'particles' },
|
||||
{ name: '>visualizer unknown-pleasures', description: 'Set preset to Unknown Pleasures', cmd: 'unknown-pleasures' }
|
||||
].map(c => ({
|
||||
...c,
|
||||
action: () => {
|
||||
if (c.cmd === 'toggle') {
|
||||
this.toggleVisualizer();
|
||||
} else {
|
||||
this.setVisualizerPreset(c.cmd);
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
type: 'command'
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const subCmd = args.trim().toLowerCase();
|
||||
if (subCmd === 'toggle') {
|
||||
this.toggleVisualizer();
|
||||
this.close();
|
||||
} else {
|
||||
const presets = ['butterchurn', 'kawarp', 'lcd', 'particles', 'unknown-pleasures'];
|
||||
if (presets.includes(subCmd)) {
|
||||
this.setVisualizerPreset(subCmd);
|
||||
this.close();
|
||||
} else {
|
||||
this.showNotification('Unknown visualizer command');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async toggleVisualizer() {
|
||||
const { visualizerSettings } = await import('./storage.js');
|
||||
const current = visualizerSettings.isEnabled();
|
||||
visualizerSettings.setEnabled(!current);
|
||||
this.showNotification(`Visualizer ${!current ? 'enabled' : 'disabled'}`);
|
||||
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
if (overlay && getComputedStyle(overlay).display !== 'none') {
|
||||
window.monochromeUi?.closeFullscreenCover();
|
||||
}
|
||||
}
|
||||
|
||||
async setVisualizerPreset(preset) {
|
||||
const { visualizerSettings } = await import('./storage.js');
|
||||
visualizerSettings.setPreset(preset);
|
||||
if (window.monochromeUi?.visualizer) {
|
||||
window.monochromeUi.visualizer.setPreset(preset);
|
||||
}
|
||||
this.showNotification(`Visualizer preset set to ${preset}`);
|
||||
}
|
||||
|
||||
async handleClearCache() {
|
||||
const api = window.monochromeUi?.api;
|
||||
if (api) {
|
||||
await api.clearCache();
|
||||
this.showNotification('Cache cleared');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
cacheAllSettings() {
|
||||
const settingItems = document.querySelectorAll('#page-settings .setting-item');
|
||||
this.allSettings = Array.from(settingItems).map(item => {
|
||||
const labelEl = item.querySelector('.label');
|
||||
const descEl = item.querySelector('.description');
|
||||
const tabEl = item.closest('.settings-tab-content');
|
||||
|
||||
const label = labelEl ? labelEl.textContent.trim() : '';
|
||||
const description = descEl ? descEl.textContent.trim() : '';
|
||||
const tab = tabEl ? tabEl.id.replace('settings-tab-', '') : '';
|
||||
|
||||
if (!item.id) {
|
||||
const inputEl = item.querySelector('input[id], select[id], button[id]');
|
||||
item.id = inputEl ? `setting-item-for-${inputEl.id}` : `setting-item-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
label,
|
||||
description,
|
||||
tab,
|
||||
};
|
||||
}).filter(s => s.label);
|
||||
}
|
||||
|
||||
async handleSettingSearch(args, autoPick = false) {
|
||||
const query = args.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
this.renderResults(this.allSettings.map(setting => ({
|
||||
name: setting.label,
|
||||
description: `[${setting.tab}] ${setting.description}`,
|
||||
action: () => {
|
||||
this.navigateToSetting(setting);
|
||||
this.close();
|
||||
},
|
||||
type: 'setting'
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const fuse = new Fuse(this.allSettings, {
|
||||
keys: ['label', 'description'],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true,
|
||||
});
|
||||
|
||||
const results = fuse.search(query).map(r => r.item);
|
||||
|
||||
if (autoPick && results.length > 0) {
|
||||
this.navigateToSetting(results[0]);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderResults(results.map(setting => ({
|
||||
name: setting.label,
|
||||
description: `[${setting.tab}] ${setting.description}`,
|
||||
action: () => {
|
||||
this.navigateToSetting(setting);
|
||||
this.close();
|
||||
},
|
||||
type: 'setting'
|
||||
})));
|
||||
}
|
||||
|
||||
async navigateToSetting(setting) {
|
||||
const router = await import('./router.js');
|
||||
router.navigate('/settings');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const tabButton = document.querySelector(`.settings-tab[data-tab="${setting.tab}"]`);
|
||||
if (tabButton && !tabButton.classList.contains('active')) {
|
||||
tabButton.click();
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const settingElement = document.getElementById(setting.id);
|
||||
if (settingElement) {
|
||||
settingElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
settingElement.style.transition = 'background-color 0.3s ease-out, box-shadow 0.3s ease-out';
|
||||
settingElement.style.backgroundColor = 'rgba(var(--highlight-rgb), 0.2)';
|
||||
settingElement.style.boxShadow = '0 0 0 2px rgba(var(--highlight-rgb), 0.5)';
|
||||
setTimeout(() => {
|
||||
settingElement.style.backgroundColor = '';
|
||||
settingElement.style.boxShadow = '';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async performSearch(cmdName, query) {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
const api = window.monochromeUi?.api;
|
||||
if (!api) return;
|
||||
|
||||
let results = [];
|
||||
|
||||
try {
|
||||
if (cmdName === 'play') {
|
||||
const data = await api.searchTracks(query);
|
||||
results = data.items.map(track => ({
|
||||
name: track.title,
|
||||
description: `${track.artist?.name || 'Unknown'} • ${track.album?.title || 'Unknown'}`,
|
||||
image: api.getCoverUrl(track.album?.cover, 80),
|
||||
action: () => {
|
||||
window.monochromePlayer.setQueue([track], 0);
|
||||
window.monochromePlayer.playTrackFromQueue();
|
||||
this.close();
|
||||
},
|
||||
type: 'result'
|
||||
}));
|
||||
} else if (cmdName === 'shuffle') {
|
||||
const [albums, artists, playlists, userPlaylists] = await Promise.all([
|
||||
api.searchAlbums(query),
|
||||
api.searchArtists(query),
|
||||
api.searchPlaylists(query),
|
||||
db.getPlaylists(true)
|
||||
]);
|
||||
|
||||
let matchedUserPlaylists = [];
|
||||
if (Fuse) {
|
||||
const fuse = new Fuse(userPlaylists, { keys: ['name'] });
|
||||
matchedUserPlaylists = fuse.search(query).map(r => r.item);
|
||||
} else {
|
||||
matchedUserPlaylists = userPlaylists.filter(p => p.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
const formatResult = (item, type, subtitle, image) => ({
|
||||
name: item.title || item.name,
|
||||
description: `${type} • ${subtitle}`,
|
||||
image: image,
|
||||
action: () => this.playCollection(item, type, true),
|
||||
type: 'result'
|
||||
});
|
||||
|
||||
results = [
|
||||
...matchedUserPlaylists.map(p => formatResult(p, 'User Playlist', `${p.tracks?.length || 0} tracks`, p.cover || (p.images && p.images[0]))),
|
||||
...artists.items.map(a => formatResult(a, 'Artist', 'Artist', api.getArtistPictureUrl(a.picture, 80))),
|
||||
...albums.items.map(a => formatResult(a, 'Album', a.artist?.name, api.getCoverUrl(a.cover, 80))),
|
||||
...playlists.items.map(p => formatResult(p, 'Playlist', p.creator?.name || 'Tidal', api.getCoverUrl(p.image, 80)))
|
||||
];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Command palette search error:', e);
|
||||
}
|
||||
|
||||
if (this.isOpen && results.length > 0) {
|
||||
this.renderResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlay(args, autoPick) {
|
||||
if (!args) return;
|
||||
|
||||
if (autoPick) {
|
||||
const api = window.monochromeUi?.api;
|
||||
const results = await api.searchTracks(args);
|
||||
if (results.items.length > 0) {
|
||||
const track = results.items[0];
|
||||
window.monochromePlayer.setQueue([track], 0);
|
||||
window.monochromePlayer.playTrackFromQueue();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleShuffle(args, autoPick) {
|
||||
if (!args) return;
|
||||
|
||||
if (autoPick) {
|
||||
this.performSearch('shuffle', args).then(() => {
|
||||
if (this.results.length > 0 && this.results[0].action) {
|
||||
this.results[0].action();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async playCollection(item, type, shuffle) {
|
||||
const player = window.monochromePlayer;
|
||||
const api = window.monochromeUi.api;
|
||||
let tracks = [];
|
||||
|
||||
try {
|
||||
if (type === 'User Playlist') {
|
||||
tracks = item.tracks;
|
||||
} else if (type === 'Artist') {
|
||||
const artist = await api.getArtist(item.id);
|
||||
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
|
||||
const trackSet = new Set();
|
||||
const allTracks = [];
|
||||
|
||||
const chunkSize = 8;
|
||||
for (let i = 0; i < allReleases.length; i += chunkSize) {
|
||||
const chunk = allReleases.slice(i, i + chunkSize);
|
||||
await Promise.all(
|
||||
chunk.map(async (album) => {
|
||||
try {
|
||||
const { tracks: albumTracks } = await api.getAlbum(album.id);
|
||||
albumTracks.forEach((track) => {
|
||||
if (!trackSet.has(track.id)) {
|
||||
trackSet.add(track.id);
|
||||
allTracks.push(track);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (allTracks.length > 0) {
|
||||
tracks = allTracks;
|
||||
} else {
|
||||
tracks = artist.tracks || [];
|
||||
}
|
||||
} else if (type === 'Album') {
|
||||
tracks = (await api.getAlbum(item.id)).tracks;
|
||||
} else if (type === 'Playlist') {
|
||||
tracks = (await api.getPlaylist(item.uuid)).tracks;
|
||||
}
|
||||
|
||||
if (tracks && tracks.length > 0) {
|
||||
if (shuffle) {
|
||||
tracks = [...tracks].sort(() => Math.random() - 0.5);
|
||||
player.shuffleActive = true;
|
||||
document.getElementById('shuffle-btn')?.classList.add('active');
|
||||
}
|
||||
player.setQueue(tracks, 0);
|
||||
player.playTrackFromQueue();
|
||||
this.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to play collection:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new CommandPalette();
|
||||
17
js/player.js
17
js/player.js
|
|
@ -901,6 +901,23 @@ export class Player {
|
|||
this.saveQueueState();
|
||||
}
|
||||
|
||||
wipeQueue() {
|
||||
this.audio.pause();
|
||||
this.audio.src = '';
|
||||
this.currentTrack = null;
|
||||
this.queue = [];
|
||||
this.shuffledQueue = [];
|
||||
this.originalQueueBeforeShuffle = [];
|
||||
this.currentQueueIndex = -1;
|
||||
this.saveQueueState();
|
||||
if (window.monochromeUi) {
|
||||
window.monochromeUi.setCurrentTrack(null);
|
||||
}
|
||||
if (window.renderQueueFunction) {
|
||||
window.renderQueueFunction();
|
||||
}
|
||||
}
|
||||
|
||||
moveInQueue(fromIndex, toIndex) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
|
||||
|
|
|
|||
66
styles.css
66
styles.css
|
|
@ -7867,3 +7867,69 @@ textarea:focus {
|
|||
.home-view {
|
||||
animation: fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
#command-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 10vh;
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.command-palette {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.command-palette-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#command-palette-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-palette-results {
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.command-result-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.command-result-item:hover, .command-result-item.selected {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.command-result-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue