Merge branch 'main' of github.com:monochrome-music/monochrome

This commit is contained in:
Samidy 2026-03-07 08:02:56 +03:00
commit 38dd6f8220
3 changed files with 246 additions and 197 deletions

View file

@ -1735,7 +1735,12 @@
<div id="command-palette-overlay" style="display: none"> <div id="command-palette-overlay" style="display: none">
<div class="command-palette"> <div class="command-palette">
<div class="command-palette-header"> <div class="command-palette-header">
<input type="text" id="command-palette-input" placeholder="Type > to start a command..." autocomplete="off" /> <input
type="text"
id="command-palette-input"
placeholder="Type > to start a command..."
autocomplete="off"
/>
</div> </div>
<div id="command-palette-results" class="command-palette-results"></div> <div id="command-palette-results" class="command-palette-results"></div>
</div> </div>
@ -2354,7 +2359,7 @@
</div> </div>
</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 id="explore-content">
<div class="card-grid" id="explore-grid"></div> <div class="card-grid" id="explore-grid"></div>
</div> </div>

View file

@ -18,48 +18,48 @@ class CommandPalette {
{ {
name: 'theme', name: 'theme',
description: 'Change theme (white, dark, ocean, purple, forest, etc.)', description: 'Change theme (white, dark, ocean, purple, forest, etc.)',
action: (args) => this.handleTheme(args) action: (args) => this.handleTheme(args),
}, },
{ {
name: 'play', name: 'play',
description: 'Search and play a track', description: 'Search and play a track',
action: (args, autoPick) => this.handlePlay(args, autoPick) action: (args, autoPick) => this.handlePlay(args, autoPick),
}, },
{ {
name: 'shuffle', name: 'shuffle',
description: 'Shuffle a playlist, artist, or album', description: 'Shuffle a playlist, artist, or album',
action: (args, autoPick) => this.handleShuffle(args, autoPick) action: (args, autoPick) => this.handleShuffle(args, autoPick),
}, },
{ {
name: 'queue', name: 'queue',
description: 'Manage the queue (wipe, like all, download)', description: 'Manage the queue (wipe, like all, download)',
action: (args) => this.handleQueue(args) action: (args) => this.handleQueue(args),
}, },
{ {
name: 'setting', name: 'setting',
description: 'Search for a specific setting', description: 'Search for a specific setting',
action: (args) => this.handleSettingSearch(args) action: (args) => this.handleSettingSearch(args),
}, },
{ {
name: 'sleep', name: 'sleep',
description: 'Set sleep timer in minutes', description: 'Set sleep timer in minutes',
action: (args) => this.handleSleepTimer(args) action: (args) => this.handleSleepTimer(args),
}, },
{ {
name: 'quality', name: 'quality',
description: 'Set streaming & download quality', description: 'Set streaming & download quality',
action: (args) => this.handleQuality(args) action: (args) => this.handleQuality(args),
}, },
{ {
name: 'visualizer', name: 'visualizer',
description: 'Control visualizer (toggle, preset)', description: 'Control visualizer (toggle, preset)',
action: (args) => this.handleVisualizer(args) action: (args) => this.handleVisualizer(args),
}, },
{ {
name: 'cache', name: 'cache',
description: 'Clear application cache', description: 'Clear application cache',
action: () => this.handleClearCache() action: () => this.handleClearCache(),
} },
]; ];
this.init(); this.init();
@ -106,12 +106,17 @@ class CommandPalette {
this.selectedIndex = 0; this.selectedIndex = 0;
if (!value.startsWith('>')) { if (!value.startsWith('>')) {
this.renderResults([{ this.renderResults([
{
name: 'Type > to use commands', name: 'Type > to use commands',
description: 'e.g. >theme White, >play The Whole World Is Free', description: 'e.g. >theme White, >play The Whole World Is Free',
action: () => { this.input.value = '>'; this.handleInput(); }, action: () => {
type: 'hint' this.input.value = '>';
}]); this.handleInput();
},
type: 'hint',
},
]);
return; return;
} }
@ -126,7 +131,7 @@ class CommandPalette {
const cmdName = match[1].toLowerCase(); const cmdName = match[1].toLowerCase();
const args = match[2] || ''; const args = match[2] || '';
const command = this.commands.find(c => c.name === cmdName); const command = this.commands.find((c) => c.name === cmdName);
if (command) { if (command) {
const commandsWithSubmenus = ['queue', 'go', 'visualizer', 'quality', 'sleep', 'setting']; const commandsWithSubmenus = ['queue', 'go', 'visualizer', 'quality', 'sleep', 'setting'];
@ -135,12 +140,14 @@ class CommandPalette {
return; return;
} }
this.renderResults([{ this.renderResults([
{
name: `Execute: ${command.name} ${args}`, name: `Execute: ${command.name} ${args}`,
description: args ? `Run ${command.name} for "${args}"` : command.description, description: args ? `Run ${command.name} for "${args}"` : command.description,
action: () => command.action(args, ['play', 'shuffle', 'setting'].includes(command.name)), action: () => command.action(args, ['play', 'shuffle', 'setting'].includes(command.name)),
type: 'execution' type: 'execution',
}]); },
]);
if (args.trim().length > 0 && (cmdName === 'play' || cmdName === 'shuffle')) { if (args.trim().length > 0 && (cmdName === 'play' || cmdName === 'shuffle')) {
this.debouncedSearch(cmdName, args.trim()); this.debouncedSearch(cmdName, args.trim());
@ -172,21 +179,23 @@ class CommandPalette {
if (filter) { if (filter) {
if (Fuse) { if (Fuse) {
const fuse = new Fuse(this.commands, { keys: ['name', 'description'] }); const fuse = new Fuse(this.commands, { keys: ['name', 'description'] });
cmds = fuse.search(filter).map(r => r.item); cmds = fuse.search(filter).map((r) => r.item);
} else { } else {
cmds = this.commands.filter(c => c.name.includes(filter)); cmds = this.commands.filter((c) => c.name.includes(filter));
} }
} }
this.renderResults(cmds.map(c => ({ this.renderResults(
cmds.map((c) => ({
name: c.name, name: c.name,
description: c.description, description: c.description,
action: () => { action: () => {
this.input.value = `>${c.name} `; this.input.value = `>${c.name} `;
this.handleInput(); this.handleInput();
}, },
type: 'command' type: 'command',
}))); }))
);
} }
renderResults(results) { renderResults(results) {
@ -194,7 +203,8 @@ class CommandPalette {
this.resultsContainer.innerHTML = ''; this.resultsContainer.innerHTML = '';
if (results.length === 0) { if (results.length === 0) {
this.resultsContainer.innerHTML = '<div style="padding: 1rem; color: var(--muted-foreground); text-align: center;">No results found</div>'; this.resultsContainer.innerHTML =
'<div style="padding: 1rem; color: var(--muted-foreground); text-align: center;">No results found</div>';
return; return;
} }
@ -202,7 +212,9 @@ class CommandPalette {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = `command-result-item ${index === this.selectedIndex ? 'selected' : ''}`; 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;">` : ''; 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.innerHTML = `
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
@ -250,7 +262,7 @@ class CommandPalette {
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
const themeOptions = document.querySelectorAll('.theme-option'); const themeOptions = document.querySelectorAll('.theme-option');
themeOptions.forEach(opt => { themeOptions.forEach((opt) => {
if (opt.dataset.theme === theme) opt.classList.add('active'); if (opt.dataset.theme === theme) opt.classList.add('active');
else opt.classList.remove('active'); else opt.classList.remove('active');
}); });
@ -271,18 +283,20 @@ class CommandPalette {
} }
if (!args || !args.trim()) { if (!args || !args.trim()) {
this.renderResults([ this.renderResults(
[
{ name: '>queue wipe', description: 'Clear the queue and stop playback' }, { name: '>queue wipe', description: 'Clear the queue and stop playback' },
{ name: '>queue like all', description: 'Like all tracks in the current queue' }, { name: '>queue like all', description: 'Like all tracks in the current queue' },
{ name: '>queue download', description: 'Download all tracks in the current queue' }, { name: '>queue download', description: 'Download all tracks in the current queue' },
].map(c => ({ ].map((c) => ({
...c, ...c,
type: 'command', type: 'command',
action: () => { action: () => {
this.input.value = c.name; this.input.value = c.name;
this.handleInput(); this.handleInput();
} },
}))); }))
);
return; return;
} }
@ -348,15 +362,17 @@ class CommandPalette {
const validPages = ['home', 'library', 'recent', 'settings', 'unreleased', 'about', 'download']; const validPages = ['home', 'library', 'recent', 'settings', 'unreleased', 'about', 'download'];
if (!args || !args.trim()) { if (!args || !args.trim()) {
this.renderResults(validPages.map(p => ({ this.renderResults(
validPages.map((p) => ({
name: `>go ${p}`, name: `>go ${p}`,
description: `Navigate to ${p}`, description: `Navigate to ${p}`,
action: () => { action: () => {
this.close(); this.close();
import('./router.js').then(m => m.navigate(p === 'home' ? '/' : `/${p}`)); import('./router.js').then((m) => m.navigate(p === 'home' ? '/' : `/${p}`));
}, },
type: 'command' type: 'command',
}))); }))
);
return; return;
} }
@ -364,7 +380,7 @@ class CommandPalette {
if (validPages.includes(page)) { if (validPages.includes(page)) {
this.close(); this.close();
import('./router.js').then(m => m.navigate(page === 'home' ? '/' : `/${page}`)); import('./router.js').then((m) => m.navigate(page === 'home' ? '/' : `/${page}`));
} else { } else {
this.showNotification(`Unknown page: ${page}`); this.showNotification(`Unknown page: ${page}`);
} }
@ -372,15 +388,17 @@ class CommandPalette {
handleSleepTimer(args) { handleSleepTimer(args) {
if (!args || !args.trim()) { if (!args || !args.trim()) {
this.renderResults([15, 30, 45, 60, 120].map(m => ({ this.renderResults(
[15, 30, 45, 60, 120].map((m) => ({
name: `>sleep ${m}`, name: `>sleep ${m}`,
description: `Set sleep timer for ${m} minutes`, description: `Set sleep timer for ${m} minutes`,
action: () => { action: () => {
this.setSleepTimer(m); this.setSleepTimer(m);
this.close(); this.close();
}, },
type: 'command' type: 'command',
}))); }))
);
return; return;
} }
@ -389,7 +407,7 @@ class CommandPalette {
this.setSleepTimer(minutes); this.setSleepTimer(minutes);
this.close(); this.close();
} else { } else {
this.showNotification("Invalid duration"); this.showNotification('Invalid duration');
} }
} }
@ -402,45 +420,45 @@ class CommandPalette {
handleQuality(args) { handleQuality(args) {
const qualityMap = { const qualityMap = {
'low': 'LOW', low: 'LOW',
'high': 'HIGH', high: 'HIGH',
'lossless': 'LOSSLESS', lossless: 'LOSSLESS',
'hires': 'HI_RES_LOSSLESS', hires: 'HI_RES_LOSSLESS',
'hi-res': 'HI_RES_LOSSLESS', 'hi-res': 'HI_RES_LOSSLESS',
'master': 'HI_RES_LOSSLESS' master: 'HI_RES_LOSSLESS',
}; };
const displayQualities = [ const displayQualities = [
{ id: 'low', name: 'Low', code: 'LOW' }, { id: 'low', name: 'Low', code: 'LOW' },
{ id: 'high', name: 'High', code: 'HIGH' }, { id: 'high', name: 'High', code: 'HIGH' },
{ id: 'lossless', name: 'Lossless', code: 'LOSSLESS' }, { id: 'lossless', name: 'Lossless', code: 'LOSSLESS' },
{ id: 'hi-res', name: 'Hi-Res', code: 'HI_RES_LOSSLESS' } { id: 'hi-res', name: 'Hi-Res', code: 'HI_RES_LOSSLESS' },
]; ];
if (!args || !args.trim()) { if (!args || !args.trim()) {
const results = displayQualities.map(q => ({ const results = displayQualities.map((q) => ({
name: `>quality ${q.id}`, name: `>quality ${q.id}`,
description: `Set quality to ${q.name}`, description: `Set quality to ${q.name}`,
action: () => { action: () => {
this.setQuality(q.code, true, true); this.setQuality(q.code, true, true);
this.close(); this.close();
}, },
type: 'command' type: 'command',
})); }));
results.push({ results.push({
name: 'Usage: >quality [level] [-S] [-D]', name: 'Usage: >quality [level] [-S] [-D]',
description: '-S for Streaming only, -D for Download only', description: '-S for Streaming only, -D for Download only',
action: () => {}, action: () => {},
type: 'hint' type: 'hint',
}); });
this.renderResults(results); this.renderResults(results);
return; return;
} }
const parts = args.trim().split(/\s+/); const parts = args.trim().split(/\s+/);
const qualityKey = parts.find(p => !p.startsWith('-'))?.toLowerCase(); const qualityKey = parts.find((p) => !p.startsWith('-'))?.toLowerCase();
const flags = parts.filter(p => p.startsWith('-')).map(f => f.toLowerCase()); const flags = parts.filter((p) => p.startsWith('-')).map((f) => f.toLowerCase());
if (!qualityKey || !qualityMap[qualityKey]) { if (!qualityKey || !qualityMap[qualityKey]) {
this.showNotification('Invalid quality setting'); this.showNotification('Invalid quality setting');
@ -496,24 +514,29 @@ class CommandPalette {
getQualityName(code) { getQualityName(code) {
const names = { const names = {
'LOW': 'Low', LOW: 'Low',
'HIGH': 'High', HIGH: 'High',
'LOSSLESS': 'Lossless', LOSSLESS: 'Lossless',
'HI_RES_LOSSLESS': 'Hi-Res' HI_RES_LOSSLESS: 'Hi-Res',
}; };
return names[code] || code; return names[code] || code;
} }
async handleVisualizer(args) { async handleVisualizer(args) {
if (!args || !args.trim()) { if (!args || !args.trim()) {
this.renderResults([ this.renderResults(
[
{ name: '>visualizer toggle', description: 'Toggle visualizer on/off', cmd: 'toggle' }, { name: '>visualizer toggle', description: 'Toggle visualizer on/off', cmd: 'toggle' },
{ name: '>visualizer butterchurn', description: 'Set preset to Butterchurn', cmd: 'butterchurn' }, { name: '>visualizer butterchurn', description: 'Set preset to Butterchurn', cmd: 'butterchurn' },
{ name: '>visualizer kawarp', description: 'Set preset to Kawarp', cmd: 'kawarp' }, { name: '>visualizer kawarp', description: 'Set preset to Kawarp', cmd: 'kawarp' },
{ name: '>visualizer lcd', description: 'Set preset to LCD', cmd: 'lcd' }, { name: '>visualizer lcd', description: 'Set preset to LCD', cmd: 'lcd' },
{ name: '>visualizer particles', description: 'Set preset to Particles', cmd: 'particles' }, { 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 => ({ name: '>visualizer unknown-pleasures',
description: 'Set preset to Unknown Pleasures',
cmd: 'unknown-pleasures',
},
].map((c) => ({
...c, ...c,
action: () => { action: () => {
if (c.cmd === 'toggle') { if (c.cmd === 'toggle') {
@ -523,8 +546,9 @@ class CommandPalette {
} }
this.close(); this.close();
}, },
type: 'command' type: 'command',
}))); }))
);
return; return;
} }
@ -575,7 +599,8 @@ class CommandPalette {
cacheAllSettings() { cacheAllSettings() {
const settingItems = document.querySelectorAll('#page-settings .setting-item'); const settingItems = document.querySelectorAll('#page-settings .setting-item');
this.allSettings = Array.from(settingItems).map(item => { this.allSettings = Array.from(settingItems)
.map((item) => {
const labelEl = item.querySelector('.label'); const labelEl = item.querySelector('.label');
const descEl = item.querySelector('.description'); const descEl = item.querySelector('.description');
const tabEl = item.closest('.settings-tab-content'); const tabEl = item.closest('.settings-tab-content');
@ -586,7 +611,9 @@ class CommandPalette {
if (!item.id) { if (!item.id) {
const inputEl = item.querySelector('input[id], select[id], button[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)}`; item.id = inputEl
? `setting-item-for-${inputEl.id}`
: `setting-item-${Math.random().toString(36).substr(2, 9)}`;
} }
return { return {
@ -595,22 +622,25 @@ class CommandPalette {
description, description,
tab, tab,
}; };
}).filter(s => s.label); })
.filter((s) => s.label);
} }
async handleSettingSearch(args, autoPick = false) { async handleSettingSearch(args, autoPick = false) {
const query = args.trim().toLowerCase(); const query = args.trim().toLowerCase();
if (!query) { if (!query) {
this.renderResults(this.allSettings.map(setting => ({ this.renderResults(
this.allSettings.map((setting) => ({
name: setting.label, name: setting.label,
description: `[${setting.tab}] ${setting.description}`, description: `[${setting.tab}] ${setting.description}`,
action: () => { action: () => {
this.navigateToSetting(setting); this.navigateToSetting(setting);
this.close(); this.close();
}, },
type: 'setting' type: 'setting',
}))); }))
);
return; return;
} }
@ -621,7 +651,7 @@ class CommandPalette {
ignoreLocation: true, ignoreLocation: true,
}); });
const results = fuse.search(query).map(r => r.item); const results = fuse.search(query).map((r) => r.item);
if (autoPick && results.length > 0) { if (autoPick && results.length > 0) {
this.navigateToSetting(results[0]); this.navigateToSetting(results[0]);
@ -629,29 +659,31 @@ class CommandPalette {
return; return;
} }
this.renderResults(results.map(setting => ({ this.renderResults(
results.map((setting) => ({
name: setting.label, name: setting.label,
description: `[${setting.tab}] ${setting.description}`, description: `[${setting.tab}] ${setting.description}`,
action: () => { action: () => {
this.navigateToSetting(setting); this.navigateToSetting(setting);
this.close(); this.close();
}, },
type: 'setting' type: 'setting',
}))); }))
);
} }
async navigateToSetting(setting) { async navigateToSetting(setting) {
const router = await import('./router.js'); const router = await import('./router.js');
router.navigate('/settings'); router.navigate('/settings');
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const tabButton = document.querySelector(`.settings-tab[data-tab="${setting.tab}"]`); const tabButton = document.querySelector(`.settings-tab[data-tab="${setting.tab}"]`);
if (tabButton && !tabButton.classList.contains('active')) { if (tabButton && !tabButton.classList.contains('active')) {
tabButton.click(); tabButton.click();
} }
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const settingElement = document.getElementById(setting.id); const settingElement = document.getElementById(setting.id);
if (settingElement) { if (settingElement) {
@ -666,7 +698,6 @@ class CommandPalette {
} }
} }
async performSearch(cmdName, query) { async performSearch(cmdName, query) {
if (!this.isOpen) return; if (!this.isOpen) return;
@ -678,7 +709,7 @@ class CommandPalette {
try { try {
if (cmdName === 'play') { if (cmdName === 'play') {
const data = await api.searchTracks(query); const data = await api.searchTracks(query);
results = data.items.map(track => ({ results = data.items.map((track) => ({
name: track.title, name: track.title,
description: `${track.artist?.name || 'Unknown'}${track.album?.title || 'Unknown'}`, description: `${track.artist?.name || 'Unknown'}${track.album?.title || 'Unknown'}`,
image: api.getCoverUrl(track.album?.cover, 80), image: api.getCoverUrl(track.album?.cover, 80),
@ -687,22 +718,24 @@ class CommandPalette {
window.monochromePlayer.playTrackFromQueue(); window.monochromePlayer.playTrackFromQueue();
this.close(); this.close();
}, },
type: 'result' type: 'result',
})); }));
} else if (cmdName === 'shuffle') { } else if (cmdName === 'shuffle') {
const [albums, artists, playlists, userPlaylists] = await Promise.all([ const [albums, artists, playlists, userPlaylists] = await Promise.all([
api.searchAlbums(query), api.searchAlbums(query),
api.searchArtists(query), api.searchArtists(query),
api.searchPlaylists(query), api.searchPlaylists(query),
db.getPlaylists(true) db.getPlaylists(true),
]); ]);
let matchedUserPlaylists = []; let matchedUserPlaylists = [];
if (Fuse) { if (Fuse) {
const fuse = new Fuse(userPlaylists, { keys: ['name'] }); const fuse = new Fuse(userPlaylists, { keys: ['name'] });
matchedUserPlaylists = fuse.search(query).map(r => r.item); matchedUserPlaylists = fuse.search(query).map((r) => r.item);
} else { } else {
matchedUserPlaylists = userPlaylists.filter(p => p.name.toLowerCase().includes(query.toLowerCase())); matchedUserPlaylists = userPlaylists.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
} }
const formatResult = (item, type, subtitle, image) => ({ const formatResult = (item, type, subtitle, image) => ({
@ -710,14 +743,25 @@ class CommandPalette {
description: `${type}${subtitle}`, description: `${type}${subtitle}`,
image: image, image: image,
action: () => this.playCollection(item, type, true), action: () => this.playCollection(item, type, true),
type: 'result' type: 'result',
}); });
results = [ results = [
...matchedUserPlaylists.map(p => formatResult(p, 'User Playlist', `${p.tracks?.length || 0} tracks`, p.cover || (p.images && p.images[0]))), ...matchedUserPlaylists.map((p) =>
...artists.items.map(a => formatResult(a, 'Artist', 'Artist', api.getArtistPictureUrl(a.picture, 80))), formatResult(
...albums.items.map(a => formatResult(a, 'Album', a.artist?.name, api.getCoverUrl(a.cover, 80))), p,
...playlists.items.map(p => formatResult(p, 'Playlist', p.creator?.name || 'Tidal', api.getCoverUrl(p.image, 80))) '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) { } catch (e) {

View file

@ -7868,11 +7868,10 @@ textarea:focus {
animation: fade-in 0.3s ease; animation: fade-in 0.3s ease;
} }
#command-palette-overlay { #command-palette-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: rgb(0, 0, 0, 0.5);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: 10000; z-index: 10000;
display: flex; display: flex;
@ -7925,7 +7924,8 @@ textarea:focus {
color: var(--foreground); color: var(--foreground);
} }
.command-result-item:hover, .command-result-item.selected { .command-result-item:hover,
.command-result-item.selected {
background: var(--secondary); background: var(--secondary);
} }