keybind rebinding

This commit is contained in:
edideaur 2026-03-03 12:20:42 +00:00
parent 9054016ff2
commit b29160385a
6 changed files with 599 additions and 71 deletions

View file

@ -1124,6 +1124,27 @@
<div class="shortcut-item"><kbd>L</kbd><span>Toggle lyrics</span></div>
<div class="shortcut-item"><kbd>/</kbd><span>Focus search</span></div>
<div class="shortcut-item"><kbd>Esc</kbd><span>Close modals</span></div>
<div class="shortcut-item"><kbd>[</kbd><span>Previous visualizer preset</span></div>
<div class="shortcut-item"><kbd>]</kbd><span>Next visualizer preset</span></div>
<div class="shortcut-item"><kbd>\</kbd><span>Toggle visualizer auto-cycle</span></div>
</div>
</div>
</div>
<div id="customize-shortcuts-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content medium">
<div class="shortcuts-header">
<h3>Customize Shortcuts</h3>
<button class="close-customize-shortcuts">&times;</button>
</div>
<div class="customize-shortcuts-content">
<p class="shortcut-hint">Click on a shortcut to rebind it. Press the new key combination.</p>
<div id="shortcuts-list" class="shortcuts-list"></div>
</div>
<div class="customize-shortcuts-actions">
<button id="reset-shortcuts-btn" class="btn-secondary">Reset to Defaults</button>
<button id="close-customize-shortcuts-btn" class="btn-primary">Done</button>
</div>
</div>
</div>
@ -5001,9 +5022,9 @@
<div class="setting-item">
<div class="info">
<span class="label">Keyboard Shortcuts</span>
<span class="description">View available keyboard shortcuts</span>
<span class="description">View and customize keyboard shortcuts</span>
</div>
<button id="show-shortcuts-btn" class="btn-secondary">Show Shortcuts</button>
<button id="customize-shortcuts-btn" class="btn-secondary">Customize</button>
</div>
<div class="setting-item">
<div class="info">

333
js/app.js
View file

@ -9,6 +9,7 @@ import {
sidebarSettings,
pwaUpdateSettings,
modalSettings,
keyboardShortcuts,
} from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
@ -160,74 +161,114 @@ function initializeCasting(audioPlayer, castBtn) {
}
function initializeKeyboardShortcuts(player, audioPlayer) {
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return;
const keyActionMap = {
playPause: () => {
trackKeyboardShortcut('Space');
player.handlePlayPause();
},
seekForward: () => {
trackKeyboardShortcut('Right');
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
},
seekBackward: () => {
trackKeyboardShortcut('Left');
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
},
nextTrack: () => {
trackKeyboardShortcut('Shift+Right');
player.playNext();
},
previousTrack: () => {
trackKeyboardShortcut('Shift+Left');
player.playPrev();
},
volumeUp: () => {
trackKeyboardShortcut('Up');
player.setVolume(player.userVolume + 0.1);
},
volumeDown: () => {
trackKeyboardShortcut('Down');
player.setVolume(player.userVolume - 0.1);
},
mute: () => {
trackKeyboardShortcut('M');
audioPlayer.muted = !audioPlayer.muted;
},
shuffle: () => {
trackKeyboardShortcut('S');
document.getElementById('shuffle-btn')?.click();
},
repeat: () => {
trackKeyboardShortcut('R');
document.getElementById('repeat-btn')?.click();
},
queue: () => {
trackKeyboardShortcut('Q');
document.getElementById('queue-btn')?.click();
},
lyrics: () => {
trackKeyboardShortcut('L');
document.querySelector('.now-playing-bar .cover')?.click();
},
search: () => {
trackKeyboardShortcut('/');
document.getElementById('search-input')?.focus();
},
escape: () => {
trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur();
sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
},
visualizerNext: () => {
trackKeyboardShortcut('VisualizerNext');
const ui = window.monochromeUi;
if (ui?.visualizer?.presets?.['butterchurn']) {
ui.visualizer.presets['butterchurn'].nextPreset();
}
},
visualizerPrev: () => {
trackKeyboardShortcut('VisualizerPrev');
const ui = window.monochromeUi;
if (ui?.visualizer?.presets?.['butterchurn']) {
ui.visualizer.presets['butterchurn'].prevPreset();
}
},
visualizerCycle: () => {
trackKeyboardShortcut('VisualizerCycle');
const ui = window.monochromeUi;
if (ui?.visualizer?.presets?.['butterchurn']) {
ui.visualizer.presets['butterchurn'].toggleCycle();
}
},
};
switch (e.key.toLowerCase()) {
case ' ':
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, [contenteditable="true"]')) return;
const shortcuts = keyboardShortcuts.getShortcuts();
const pressedKey = e.key.toLowerCase();
const hasShift = e.shiftKey;
const hasCtrl = e.ctrlKey || e.metaKey;
const hasAlt = e.altKey;
for (const [action, shortcut] of Object.entries(shortcuts)) {
if (!shortcut?.key) continue;
const shortcutKey = shortcut.key.toLowerCase();
const matches =
pressedKey === shortcutKey &&
shortcut.shift === hasShift &&
shortcut.ctrl === hasCtrl &&
shortcut.alt === hasAlt;
if (matches) {
e.preventDefault();
trackKeyboardShortcut('Space');
player.handlePlayPause();
break;
case 'arrowright':
if (e.shiftKey) {
trackKeyboardShortcut('Shift+Right');
player.playNext();
} else {
trackKeyboardShortcut('Right');
audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10);
const actionFn = keyActionMap[action];
if (actionFn) {
actionFn();
}
break;
case 'arrowleft':
if (e.shiftKey) {
trackKeyboardShortcut('Shift+Left');
player.playPrev();
} else {
trackKeyboardShortcut('Left');
audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10);
}
break;
case 'arrowup':
e.preventDefault();
trackKeyboardShortcut('Up');
player.setVolume(player.userVolume + 0.1);
break;
case 'arrowdown':
e.preventDefault();
trackKeyboardShortcut('Down');
player.setVolume(player.userVolume - 0.1);
break;
case 'm':
trackKeyboardShortcut('M');
audioPlayer.muted = !audioPlayer.muted;
break;
case 's':
trackKeyboardShortcut('S');
document.getElementById('shuffle-btn')?.click();
break;
case 'r':
trackKeyboardShortcut('R');
document.getElementById('repeat-btn')?.click();
break;
case 'q':
trackKeyboardShortcut('Q');
document.getElementById('queue-btn')?.click();
break;
case '/':
e.preventDefault();
trackKeyboardShortcut('/');
document.getElementById('search-input')?.focus();
break;
case 'escape':
trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur();
sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
break;
case 'l':
trackKeyboardShortcut('L');
document.querySelector('.now-playing-bar .cover')?.click();
break;
return;
}
}
});
}
@ -378,6 +419,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeCasting(audioPlayer, castBtn);
const ui = new UIRenderer(api, player);
window.monochromeUi = ui;
const scrobbler = new MultiScrobbler();
const lyricsManager = new LyricsManager(api);
@ -2462,6 +2504,10 @@ document.addEventListener('DOMContentLoaded', async () => {
showKeyboardShortcuts();
});
document.getElementById('customize-shortcuts-btn')?.addEventListener('click', () => {
showCustomizeShortcutsModal();
});
// Font Settings
const fontSelect = document.getElementById('font-select');
if (fontSelect) {
@ -2846,3 +2892,160 @@ function showKeyboardShortcuts() {
modal.addEventListener('click', handleClose);
modal.classList.add('active');
}
function showCustomizeShortcutsModal() {
const modal = document.getElementById('customize-shortcuts-modal');
const shortcutsList = document.getElementById('shortcuts-list');
let recordingAction = null;
let recordingTimeout = null;
const shortcuts = keyboardShortcuts.getShortcuts();
const formatKey = (key) => {
if (!key) return 'none';
const keyMap = {
' ': 'Space',
arrowup: '↑',
arrowdown: '↓',
arrowleft: '←',
arrowright: '→',
escape: 'Esc',
backspace: 'Backspace',
delete: 'Delete',
insert: 'Insert',
home: 'Home',
end: 'End',
pageup: 'Page Up',
pagedown: 'Page Down',
'[': '[',
']': ']',
'\\': '\\',
tab: 'Tab',
enter: 'Enter',
capslock: 'Caps Lock',
shift: 'Shift',
control: 'Ctrl',
alt: 'Alt',
meta: 'Meta',
contextmenu: 'Context Menu',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const renderShortcuts = () => {
shortcutsList.innerHTML = '';
const currentShortcuts = keyboardShortcuts.getShortcuts();
for (const [action, shortcut] of Object.entries(currentShortcuts || {})) {
const item = document.createElement('div');
item.className = 'customize-shortcut-item';
item.dataset.action = action;
const modifiers = [];
if (shortcut?.shift) modifiers.push('Shift');
if (shortcut?.ctrl) modifiers.push('Ctrl');
if (shortcut?.alt) modifiers.push('Alt');
const keyDisplay = [...modifiers, formatKey(shortcut?.key)].join(' + ');
item.innerHTML = `
<span class="shortcut-description">${shortcut?.description || 'Unknown'}</span>
<div class="shortcut-key">
<kbd class="${recordingAction === action ? 'recording' : ''}">${keyDisplay}</kbd>
<button class="shortcut-btn" title="Reset to default">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
</button>
</div>
`;
const kbd = item.querySelector('kbd');
kbd.addEventListener('click', (e) => {
e.stopPropagation();
if (recordingAction === action) {
recordingAction = null;
clearTimeout(recordingTimeout);
} else {
recordingAction = action;
recordingTimeout = setTimeout(() => {
keyboardShortcuts.setShortcut(action, {
key: null,
shift: false,
ctrl: false,
alt: false,
description: shortcut?.description || 'Unknown',
});
recordingAction = null;
renderShortcuts();
}, 3000);
}
renderShortcuts();
});
const resetBtn = item.querySelector('.shortcut-btn');
resetBtn.addEventListener('click', (e) => {
e.stopPropagation();
const defaults = keyboardShortcuts.getDefaultShortcuts();
keyboardShortcuts.setShortcut(action, defaults[action]);
renderShortcuts();
});
shortcutsList.appendChild(item);
}
};
const handleKeyDown = (e) => {
if (!recordingAction) return;
e.preventDefault();
e.stopPropagation();
const key = e.key === ' ' ? ' ' : e.key;
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) {
return;
}
keyboardShortcuts.setShortcut(recordingAction, {
key: key,
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
alt: e.altKey,
});
clearTimeout(recordingTimeout);
recordingAction = null;
renderShortcuts();
};
const closeModal = () => {
modal.classList.remove('active');
recordingAction = null;
clearTimeout(recordingTimeout);
document.removeEventListener('keydown', handleKeyDown);
modal.removeEventListener('click', handleClose);
};
const handleClose = (e) => {
if (
e.target === modal ||
e.target.classList.contains('close-customize-shortcuts') ||
e.target.id === 'close-customize-shortcuts-btn' ||
e.target.classList.contains('modal-overlay')
) {
closeModal();
}
};
document.getElementById('reset-shortcuts-btn')?.addEventListener('click', () => {
keyboardShortcuts.resetShortcuts();
renderShortcuts();
});
document.addEventListener('keydown', handleKeyDown);
modal.addEventListener('click', handleClose);
renderShortcuts();
modal.classList.add('active');
}

View file

@ -35,6 +35,7 @@ import {
musicProviderSettings,
analyticsSettings,
modalSettings,
keyboardShortcuts,
} from './storage.js';
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
import { getButterchurnPresets } from './visualizers/butterchurn.js';

View file

@ -2527,3 +2527,71 @@ export const contentBlockingSettings = {
localStorage.removeItem(this.BLOCKED_ALBUMS_KEY);
},
};
export const keyboardShortcuts = {
STORAGE_KEY: 'keyboard-shortcuts',
DEFAULT_SHORTCUTS: {
playPause: { key: ' ', shift: false, ctrl: false, alt: false, description: 'Play / Pause' },
seekForward: { key: 'arrowright', shift: false, ctrl: false, alt: false, description: 'Seek forward 10s' },
seekBackward: { key: 'arrowleft', shift: false, ctrl: false, alt: false, description: 'Seek backward 10s' },
nextTrack: { key: 'arrowright', shift: true, ctrl: false, alt: false, description: 'Next track' },
previousTrack: { key: 'arrowleft', shift: true, ctrl: false, alt: false, description: 'Previous track' },
volumeUp: { key: 'arrowup', shift: false, ctrl: false, alt: false, description: 'Volume up' },
volumeDown: { key: 'arrowdown', shift: false, ctrl: false, alt: false, description: 'Volume down' },
mute: { key: 'm', shift: false, ctrl: false, alt: false, description: 'Mute / Unmute' },
shuffle: { key: 's', shift: false, ctrl: false, alt: false, description: 'Toggle shuffle' },
repeat: { key: 'r', shift: false, ctrl: false, alt: false, description: 'Toggle repeat' },
queue: { key: 'q', shift: false, ctrl: false, alt: false, description: 'Open queue' },
lyrics: { key: 'l', shift: false, ctrl: false, alt: false, description: 'Toggle lyrics' },
search: { key: '/', shift: false, ctrl: false, alt: false, description: 'Focus search' },
escape: { key: 'escape', shift: false, ctrl: false, alt: false, description: 'Close modals' },
visualizerNext: { key: ']', shift: false, ctrl: false, alt: false, description: 'Next visualizer preset' },
visualizerPrev: { key: '[', shift: false, ctrl: false, alt: false, description: 'Previous visualizer preset' },
visualizerCycle: {
key: '\\',
shift: false,
ctrl: false,
alt: false,
description: 'Toggle visualizer auto-cycle',
},
},
getShortcuts() {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
return parsed;
}
}
} catch (e) {
console.warn('Failed to load keyboard shortcuts:', e);
}
return this.getDefaultShortcuts();
},
getDefaultShortcuts() {
return { ...this.DEFAULT_SHORTCUTS };
},
setShortcut(action, shortcut) {
const shortcuts = this.getShortcuts();
const defaults = this.DEFAULT_SHORTCUTS;
shortcuts[action] = {
...(defaults[action] || {}),
...shortcut,
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(shortcuts));
},
resetShortcuts() {
localStorage.removeItem(this.STORAGE_KEY);
},
getShortcutForAction(action) {
const shortcuts = this.getShortcuts();
return shortcuts[action] || this.DEFAULT_SHORTCUTS[action];
},
};

View file

@ -110,6 +110,15 @@ export class ButterchurnPreset {
this.presets = cachedPresets || {};
this.presetKeys = cachedPresetKeys || [];
// Shuffled queue for random mode
this.shuffledQueue = [];
this.shuffledIndex = 0;
// Generate shuffled queue if presets are already loaded
if (this.presetKeys.length > 0) {
this.generateShuffledQueue();
}
// Transition settings
this.blendProgress = 0;
this.blendDuration = 2.7; // seconds for preset transitions
@ -119,6 +128,7 @@ export class ButterchurnPreset {
onButterchurnPresetsLoaded((presets, keys) => {
this.presets = presets;
this.presetKeys = keys;
this.generateShuffledQueue();
// Notify system that presets are ready (for settings dropdown)
window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded'));
@ -131,6 +141,44 @@ export class ButterchurnPreset {
}
}
/**
* Generate a shuffled queue of preset indices
*/
generateShuffledQueue() {
const indices = this.presetKeys.map((_, i) => i);
// Fisher-Yates shuffle
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
this.shuffledQueue = indices;
this.shuffledIndex = 0;
}
/**
* Get the current preset index based on mode
*/
getCurrentIndex() {
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
if (randomize && this.shuffledQueue.length > 0) {
return this.shuffledQueue[this.shuffledIndex];
}
return this.currentPresetIndex;
}
/**
* Set the current preset index based on mode
*/
setCurrentIndex(index) {
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
if (randomize && this.shuffledQueue.length > 0) {
this.shuffledIndex = this.shuffledQueue.indexOf(index);
if (this.shuffledIndex === -1) this.shuffledIndex = 0;
} else {
this.currentPresetIndex = index;
}
}
/**
* Get the preset cycle duration from settings (in milliseconds)
*/
@ -218,7 +266,16 @@ export class ButterchurnPreset {
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
if (randomize) {
this.currentPresetIndex = Math.floor(Math.random() * this.presetKeys.length);
if (this.shuffledQueue.length === 0) {
this.generateShuffledQueue();
}
this.shuffledIndex = (this.shuffledIndex + 1) % this.shuffledQueue.length;
if (this.shuffledIndex === 0) {
// Re-shuffle when we've gone through all presets
this.generateShuffledQueue();
this.shuffledIndex = 0;
}
this.currentPresetIndex = this.shuffledQueue[this.shuffledIndex];
} else {
this.currentPresetIndex = (this.currentPresetIndex + 1) % this.presetKeys.length;
}
@ -253,7 +310,7 @@ export class ButterchurnPreset {
// Update current index if found
const index = this.presetKeys.indexOf(presetName);
if (index !== -1) {
this.currentPresetIndex = index;
this.setCurrentIndex(index);
}
}
}
@ -269,17 +326,85 @@ export class ButterchurnPreset {
* Get current preset name
*/
getCurrentPresetName() {
return this.presetKeys[this.currentPresetIndex] || 'Unknown';
const index = this.getCurrentIndex();
return this.presetKeys[index] || 'Unknown';
}
/**
* Skip to next preset (manually triggered)
* Uses shuffled queue in random mode, sequential in normal mode
*/
nextPreset() {
this.loadNextPreset();
if (!this.visualizer || this.presetKeys.length === 0) return;
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
if (randomize) {
this.shuffledIndex = (this.shuffledIndex + 1) % this.shuffledQueue.length;
if (this.shuffledIndex === 0) {
// Re-shuffle when we've gone through all presets
this.generateShuffledQueue();
this.shuffledIndex = 0;
}
this.currentPresetIndex = this.shuffledQueue[this.shuffledIndex];
} else {
this.currentPresetIndex = (this.currentPresetIndex + 1) % this.presetKeys.length;
}
const presetKey = this.presetKeys[this.currentPresetIndex];
const preset = this.presets[presetKey];
if (preset) {
try {
this.visualizer.loadPreset(preset, this.blendDuration);
} catch (error) {
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
if (this.presetKeys.length > 1) {
this.nextPreset();
}
}
}
this.lastPresetChange = performance.now();
}
/**
* Skip to previous preset (manually triggered)
* Uses shuffled queue in random mode, sequential in normal mode
*/
prevPreset() {
if (!this.visualizer || this.presetKeys.length === 0) return;
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
if (randomize) {
this.shuffledIndex = (this.shuffledIndex - 1 + this.shuffledQueue.length) % this.shuffledQueue.length;
this.currentPresetIndex = this.shuffledQueue[this.shuffledIndex];
} else {
this.currentPresetIndex = (this.currentPresetIndex - 1 + this.presetKeys.length) % this.presetKeys.length;
}
const presetKey = this.presetKeys[this.currentPresetIndex];
const preset = this.presets[presetKey];
if (preset) {
try {
this.visualizer.loadPreset(preset, this.blendDuration);
} catch (error) {
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
}
}
this.lastPresetChange = performance.now();
}
/**
* Toggle auto-cycle on/off
*/
toggleCycle() {
const current = visualizerSettings.isButterchurnCycleEnabled();
visualizerSettings.setButterchurnCycleEnabled(!current);
return !current;
}
/**
* Resize handler
*/

View file

@ -4635,6 +4635,116 @@ input:checked + .slider::before {
border-bottom: none;
}
.customize-shortcuts-content {
padding: 1rem;
}
.shortcut-hint {
color: var(--muted-foreground);
font-size: 0.85rem;
margin-bottom: 1rem;
}
.shortcuts-list {
/* list scrolls with modal */
}
.customize-shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.customize-shortcut-item:last-child {
border-bottom: none;
}
.customize-shortcut-item .shortcut-description {
flex: 1;
}
.customize-shortcut-item .shortcut-key {
display: flex;
gap: 0.25rem;
align-items: center;
}
.customize-shortcut-item kbd {
background: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
font-family: inherit;
min-width: 28px;
text-align: center;
}
.customize-shortcut-item kbd.recording {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.customize-shortcut-item .shortcut-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
cursor: pointer;
padding: 0.25rem;
margin-left: 0.5rem;
opacity: 0;
transition: opacity var(--transition);
}
.customize-shortcut-item:hover .shortcut-btn {
opacity: 1;
}
.customize-shortcut-item .shortcut-btn:hover {
color: var(--foreground);
}
.customize-shortcuts-actions {
display: flex;
justify-content: space-between;
padding: 1rem;
border-top: 1px solid var(--border);
}
.close-customize-shortcuts {
background: transparent;
border: none;
color: var(--muted-foreground);
font-size: 1.5rem;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
transition: all var(--transition);
}
.close-customize-shortcuts:hover {
background: var(--secondary);
color: var(--foreground);
}
#playlist-detail-description,
#mix-detail-description {
color: var(--foreground);