feat(web): desktop keyboard shortcuts

Space = play/pause, ←/→ = seek 5s, Shift+←/→ = prev/next, ↑/↓ = volume.
Handler lives in player.js (where _pos/_dur/seekToMs are in scope), wired
from startApp via initKeyboardShortcuts(). Ignored while typing in inputs,
and yields Space to focused buttons/links so it never double-fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-06-26 14:21:36 +02:00
1 parent ffa4fb35ea
commit 7498e45b16
3 files changed
+53 -1

No files matched your search

+2 -1
View File
@@ -9,7 +9,7 @@ import {
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState,
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
} from './player/player.js';
import {
navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch,
@@ -220,6 +220,7 @@ window.addEventListener('load', () => {
injectPWA();
initProgressScrub();
initKeyboardShortcuts();
// Click player-art to open Now Playing
document.getElementById('player-art')?.addEventListener('click', openNowPlaying);
+50
View File
@@ -898,6 +898,56 @@ export function seekToMs(ms) {
}
}
// ─── DESKTOP KEYBOARD SHORTCUTS ───
// Space = play/pause · ←/→ = seek 5s · Shift+←/→ = prev/next · ↑/↓ = volume.
// Skipped while typing in an input so it never hijacks the search/playlist fields.
export function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return; // leave OS/browser combos alone
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable)) return;
switch (e.code) {
case 'Space':
// Don't hijack Space when a button/link is focused — it would double-fire.
if (t && (t.tagName === 'BUTTON' || t.tagName === 'A' ||
t.closest?.('button, a, [role="button"]'))) return;
e.preventDefault();
togglePlay();
break;
case 'ArrowRight':
e.preventDefault();
if (e.shiftKey) nextSong(); else _seekBy(5);
break;
case 'ArrowLeft':
e.preventDefault();
if (e.shiftKey) prevSong(); else _seekBy(-5);
break;
case 'ArrowUp':
e.preventDefault();
_bumpVolume(5);
break;
case 'ArrowDown':
e.preventDefault();
_bumpVolume(-5);
break;
}
});
}
function _seekBy(deltaSec) {
const dur = _dur();
if (!dur) return;
seekToMs(Math.max(0, Math.min(dur, _pos() + deltaSec)) * 1000);
}
function _bumpVolume(deltaSlider) {
// setVolume takes a 0–100 slider value (squared internally); recover it from the actual volume.
const cur = Math.round(Math.sqrt(Math.max(0, S.audio.volume || 0)) * 100);
setVolume(Math.max(0, Math.min(100, cur + deltaSlider)));
}
function _initBarScrub(bar, fill, onMove, noTouch = false) {
if(!bar || !fill) return;
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }