From 7498e45b16014571a951152e5fa4f7f24a8f4052 Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 26 Jun 2026 14:21:36 +0200 Subject: [PATCH] feat(web): desktop keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + src/main.js | 3 ++- src/player/player.js | 50 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75de664..27750de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an - **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it - **Web + Android** — Lyrics prefetch: lyrics are fetched the moment a track starts playing (not only when the Lyrics tab is opened), so opening Lyrics is instant. The lrclib cache is warmed on every song change - **Web (PWA)** — Desktop install polish: `manifest.json` now ships PNG icons (192/512) for a crisp taskbar/Start-menu icon, drops the `portrait` lock so the window resizes freely, and adds a stable app `id` — installs as a standalone desktop window (no browser chrome) +- **Web** — Keyboard shortcuts (desktop): Space = play/pause, ←/→ = seek 5 s, Shift+←/→ = previous/next track, ↑/↓ = volume. Ignored while typing in inputs/search fields ### Fixed - **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab diff --git a/src/main.js b/src/main.js index f4d5c0f..9ff8fed 100644 --- a/src/main.js +++ b/src/main.js @@ -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); diff --git a/src/player/player.js b/src/player/player.js index 81572b5..8186577 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -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)); }