From b29160385ad97bd3e50d2da18094cae61219bf6d Mon Sep 17 00:00:00 2001 From: edideaur Date: Tue, 3 Mar 2026 12:20:42 +0000 Subject: [PATCH] keybind rebinding --- index.html | 25 ++- js/app.js | 333 +++++++++++++++++++++++++++------- js/settings.js | 1 + js/storage.js | 68 +++++++ js/visualizers/butterchurn.js | 133 +++++++++++++- styles.css | 110 +++++++++++ 6 files changed, 599 insertions(+), 71 deletions(-) diff --git a/index.html b/index.html index 56e27bd..3155203 100644 --- a/index.html +++ b/index.html @@ -1124,6 +1124,27 @@
LToggle lyrics
/Focus search
EscClose modals
+
[Previous visualizer preset
+
]Next visualizer preset
+
\Toggle visualizer auto-cycle
+ + + + + @@ -5001,9 +5022,9 @@
Keyboard Shortcuts - View available keyboard shortcuts + View and customize keyboard shortcuts
- +
diff --git a/js/app.js b/js/app.js index 33cdd82..c9f0b9f 100644 --- a/js/app.js +++ b/js/app.js @@ -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 = ` + ${shortcut?.description || 'Unknown'} +
+ ${keyDisplay} + +
+ `; + + 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'); +} diff --git a/js/settings.js b/js/settings.js index b6c21b0..2e25ced 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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'; diff --git a/js/storage.js b/js/storage.js index 618f40f..14f70cc 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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]; + }, +}; diff --git a/js/visualizers/butterchurn.js b/js/visualizers/butterchurn.js index 0bd0b69..e040cd5 100644 --- a/js/visualizers/butterchurn.js +++ b/js/visualizers/butterchurn.js @@ -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 */ diff --git a/styles.css b/styles.css index 4f2fa45..e0b0796 100644 --- a/styles.css +++ b/styles.css @@ -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);