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:
1 parent
ffa4fb35ea
commit
7498e45b16
3 files changed
+53
-1
No files matched your search
@@ -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
|
- **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 + 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 (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
|
### 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
|
- **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
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@ import {
|
|||||||
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
|
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
|
||||||
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
|
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
|
||||||
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
|
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
|
||||||
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState,
|
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
|
||||||
} from './player/player.js';
|
} from './player/player.js';
|
||||||
import {
|
import {
|
||||||
navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch,
|
navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch,
|
||||||
@@ -220,6 +220,7 @@ window.addEventListener('load', () => {
|
|||||||
|
|
||||||
injectPWA();
|
injectPWA();
|
||||||
initProgressScrub();
|
initProgressScrub();
|
||||||
|
initKeyboardShortcuts();
|
||||||
|
|
||||||
// Click player-art to open Now Playing
|
// Click player-art to open Now Playing
|
||||||
document.getElementById('player-art')?.addEventListener('click', openNowPlaying);
|
document.getElementById('player-art')?.addEventListener('click', openNowPlaying);
|
||||||
|
|||||||
@@ -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) {
|
function _initBarScrub(bar, fill, onMove, noTouch = false) {
|
||||||
if(!bar || !fill) return;
|
if(!bar || !fill) return;
|
||||||
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }
|
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }
|
||||||
|
|||||||
Reference in new issue
Block a user