From 1e0518041834e32fdfa3e42f6aba6643c8c48fc9 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 3 May 2026 00:37:43 +0300 Subject: [PATCH] feat(shortcuts): action registry + dynamic CLI help + new input targets (#435) * feat(shortcuts): unify action-driven shortcut and CLI routing Centralize shortcut action metadata in one TypeScript registry and route keyboard, global shortcut, mini-window, and CLI inputs through shared runtime handlers. Keep CLI as an abstract transport layer by emitting player-command payloads without depending on shortcut definitions. * feat(shortcuts): generate CLI action help from shortcut registry Move no-arg player commands and their descriptions into the central action registry so CLI parsing and --player help are derived dynamically from one source of truth. Also route runtime action execution through the registry and remove duplicated shortcut runtime handling. * feat(shortcuts): add new input actions and hidden F1 help binding Add the requested input actions (search, advanced search, sidebar, mute, equalizer, repeat, now playing, lyrics, favorite current track) to the central shortcut action registry and wire runtime handlers for sidebar/equalizer toggles. Keep Help bound to F1 by default while hiding it from Settings input lists, and backfill persisted keybindings with new defaults so F1 works for existing users. Requested by @zunoz (Discord community). --- src-tauri/src/cli.rs | 254 ++++++--- .../src/lib_commands/app_api/integration.rs | 10 +- src/App.tsx | 236 ++------ src/components/MiniPlayer.tsx | 20 +- src/components/PlayerBar.tsx | 6 + src/config/shortcutActions.ts | 521 ++++++++++++++++++ src/locales/en.ts | 10 + src/pages/Settings.tsx | 26 +- src/shortcuts/runtime.ts | 10 + src/store/globalShortcutsStore.ts | 10 +- src/store/keybindingsStore.ts | 52 +- 11 files changed, 820 insertions(+), 335 deletions(-) create mode 100644 src/config/shortcutActions.ts create mode 100644 src/shortcuts/runtime.ts diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index e686a1c0..3bdfc5a3 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -5,7 +5,6 @@ const COMPLETIONS_BASH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), const COMPLETIONS_ZSH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../completions/_psysonic")); use std::path::PathBuf; -#[cfg(target_os = "linux")] use std::sync::OnceLock; use std::time::{Duration, Instant}; use std::{ @@ -32,22 +31,12 @@ pub enum SearchCliScope { #[derive(Clone, Debug, PartialEq, Eq)] pub enum PlayerCliCmd { - Next, - Prev, - Play, + NoArgCommand(String), PlayOpaqueId(String), - Pause, - Stop, Seek { delta_secs: i32 }, Volume { percent: u8 }, - ShuffleQueue, Repeat(RepeatCliMode), - Mute, - Unmute, - StarCurrent, - UnstarCurrent, Rating { stars: u8 }, - ReloadPlayer, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -74,6 +63,105 @@ pub enum CliCommand { }, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct CliActionRegistryEntry { + command: String, + verb: String, + description: String, +} + +fn shortcut_actions_registry_source() -> &'static str { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../src/config/shortcutActions.ts" + )) +} + +fn extract_quoted_field(line: &str, key: &str) -> Option { + let needle = format!("{key}: '"); + let start = line.find(&needle)? + needle.len(); + let tail = &line[start..]; + let end = tail.find('\'')?; + Some(tail[..end].to_string()) +} + +fn parse_registry_action_id(line: &str) -> Option { + let trimmed = line.trim_start(); + if !trimmed.ends_with('{') { + return None; + } + if trimmed.starts_with('\'') { + let rest = &trimmed[1..]; + let end = rest.find('\'')?; + let id = &rest[..end]; + let tail = rest[end + 1..].trim_start(); + if !tail.starts_with(':') { + return None; + } + return Some(id.to_string()); + } + let brace_idx = trimmed.find(':')?; + let candidate = trimmed[..brace_idx].trim(); + if candidate.is_empty() || !trimmed[brace_idx + 1..].trim_start().starts_with('{') { + return None; + } + if !candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return None; + } + Some(candidate.to_string()) +} + +fn parse_cli_action_registry_entries() -> Vec { + let mut entries = Vec::new(); + let mut current_action_id: Option = None; + + for line in shortcut_actions_registry_source().lines() { + if let Some(id) = parse_registry_action_id(line) { + current_action_id = Some(id); + continue; + } + let trimmed = line.trim(); + if !trimmed.starts_with("cli: {") { + continue; + } + let Some(action_id) = current_action_id.clone() else { + continue; + }; + let Some(verb) = extract_quoted_field(trimmed, "verb") else { + continue; + }; + let Some(description) = extract_quoted_field(trimmed, "description") else { + continue; + }; + let command = extract_quoted_field(trimmed, "command").unwrap_or_else(|| action_id.clone()); + entries.push(CliActionRegistryEntry { + command, + verb, + description, + }); + } + + entries +} + +fn cli_action_registry_entries() -> &'static Vec { + static ENTRIES: OnceLock> = OnceLock::new(); + ENTRIES.get_or_init(parse_cli_action_registry_entries) +} + +fn cli_registry_entry_by_verb(verb: &str) -> Option<&'static CliActionRegistryEntry> { + cli_action_registry_entries().iter().find(|entry| entry.verb == verb) +} + +fn cli_registry_entry_by_command(command: &str) -> Option<&'static CliActionRegistryEntry> { + cli_action_registry_entries() + .iter() + .find(|entry| entry.command == command) +} + pub fn wants_version(args: &[String]) -> bool { args.iter() .skip(1) @@ -281,16 +369,19 @@ pub fn print_help(program: &str) { eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout."); eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n"); eprintln!(" Playback"); - eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); + eprintln!(" {program} [--quiet|-q] --player "); + for entry in cli_action_registry_entries() { + eprintln!( + " {program} [--quiet|-q] --player {:<14} {}", + entry.verb, entry.description + ); + } eprintln!(" {program} [--quiet|-q] --player play Track, album, or artist id (artist → shuffled library)."); eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent."); - eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue."); eprintln!(" {program} [--quiet|-q] --player repeat off|all|one"); - eprintln!(" {program} [--quiet|-q] --player mute | unmute"); - eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star)."); eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears)."); - eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n"); + eprintln!(); eprintln!(" Audio output"); eprintln!(" {program} [--json] --player audio-device list"); eprintln!(" {program} --player audio-device set \n"); @@ -879,11 +970,9 @@ fn parse_repeat_mode(arg: &str) -> Option { fn parse_player_cli_at(args: &[String], pos: usize) -> Option { let verb = args.get(pos + 1)?.as_str(); - match verb { - "next" => Some(PlayerCliCmd::Next), - "prev" => Some(PlayerCliCmd::Prev), - "play" => match args.get(pos + 2).map(|s| s.as_str()) { - None => Some(PlayerCliCmd::Play), + if let Some(entry) = cli_registry_entry_by_verb(verb).filter(|entry| entry.command == "play") { + return match args.get(pos + 2).map(|s| s.as_str()) { + None => Some(PlayerCliCmd::NoArgCommand(entry.command.clone())), Some(flag) if flag.starts_with('-') => None, Some(extra) => { if extra.is_empty() { @@ -891,18 +980,13 @@ fn parse_player_cli_at(args: &[String], pos: usize) -> Option { } Some(PlayerCliCmd::PlayOpaqueId(extra.to_string())) } - }, - "pause" => Some(PlayerCliCmd::Pause), - "stop" => Some(PlayerCliCmd::Stop), - "shuffle" => Some(PlayerCliCmd::ShuffleQueue), + }; + } + match verb { "repeat" => { let m = parse_repeat_mode(args.get(pos + 2)?.as_str())?; Some(PlayerCliCmd::Repeat(m)) } - "mute" => Some(PlayerCliCmd::Mute), - "unmute" => Some(PlayerCliCmd::Unmute), - "star" => Some(PlayerCliCmd::StarCurrent), - "unstar" => Some(PlayerCliCmd::UnstarCurrent), "rating" => { let raw = args.get(pos + 2)?; let n: u8 = raw.parse().ok()?; @@ -911,7 +995,6 @@ fn parse_player_cli_at(args: &[String], pos: usize) -> Option { } Some(PlayerCliCmd::Rating { stars: n }) } - "reload" => Some(PlayerCliCmd::ReloadPlayer), "seek" => { let raw = args.get(pos + 2)?; let delta_secs: i32 = raw.parse().ok()?; @@ -927,7 +1010,8 @@ fn parse_player_cli_at(args: &[String], pos: usize) -> Option { percent: v as u8, }) } - _ => None, + _ => cli_registry_entry_by_verb(verb) + .map(|entry| PlayerCliCmd::NoArgCommand(entry.command.clone())), } } @@ -1214,58 +1298,68 @@ pub fn describe_cli_command(cmd: &CliCommand) -> String { } pub fn describe_player_cli_cmd(cmd: &PlayerCliCmd) -> String { + if let PlayerCliCmd::NoArgCommand(command) = cmd { + if let Some(entry) = cli_registry_entry_by_command(command) { + return entry.verb.clone(); + } + return command.clone(); + } match cmd { - PlayerCliCmd::Next => "next track".into(), - PlayerCliCmd::Prev => "previous track".into(), - PlayerCliCmd::Play => "play".into(), PlayerCliCmd::PlayOpaqueId(id) => format!("play {id}"), - PlayerCliCmd::Pause => "pause".into(), - PlayerCliCmd::Stop => "stop".into(), PlayerCliCmd::Seek { delta_secs } => format!("seek {delta_secs:+} s"), PlayerCliCmd::Volume { percent } => format!("volume {percent}%"), - PlayerCliCmd::ShuffleQueue => "shuffle".into(), PlayerCliCmd::Repeat(m) => match m { RepeatCliMode::Off => "repeat off".into(), RepeatCliMode::All => "repeat all".into(), RepeatCliMode::One => "repeat one".into(), }, - PlayerCliCmd::Mute => "mute".into(), - PlayerCliCmd::Unmute => "unmute".into(), - PlayerCliCmd::StarCurrent => "star".into(), - PlayerCliCmd::UnstarCurrent => "unstar".into(), PlayerCliCmd::Rating { stars } => format!("rating {stars}"), - PlayerCliCmd::ReloadPlayer => "reload".into(), + PlayerCliCmd::NoArgCommand(command) => command.clone(), } } +fn emit_cli_player_command(app: &AppHandle, payload: serde_json::Value) { + let _ = app.emit("cli:player-command", payload); +} + pub fn emit_player_cli_cmd(app: &AppHandle, cmd: PlayerCliCmd) { + if let PlayerCliCmd::NoArgCommand(command) = &cmd { + emit_cli_player_command( + app, + serde_json::json!({ + "command": command + }), + ); + return; + } + match cmd { - PlayerCliCmd::Next => { - let _ = app.emit("media:next", ()); - } - PlayerCliCmd::Prev => { - let _ = app.emit("media:prev", ()); - } - PlayerCliCmd::Play => { - let _ = app.emit("media:play", ()); - } PlayerCliCmd::PlayOpaqueId(id) => { - let _ = app.emit("cli:play-id", id); - } - PlayerCliCmd::Pause => { - let _ = app.emit("media:pause", ()); - } - PlayerCliCmd::Stop => { - let _ = app.emit("media:stop", ()); + emit_cli_player_command( + app, + serde_json::json!({ + "command": "play-id", + "id": id + }), + ); } PlayerCliCmd::Seek { delta_secs } => { - let _ = app.emit("media:seek-relative", delta_secs); + emit_cli_player_command( + app, + serde_json::json!({ + "command": "seek-relative", + "deltaSecs": delta_secs + }), + ); } PlayerCliCmd::Volume { percent } => { - let _ = app.emit("media:set-volume", percent); - } - PlayerCliCmd::ShuffleQueue => { - let _ = app.emit("cli:shuffle-queue", ()); + emit_cli_player_command( + app, + serde_json::json!({ + "command": "set-volume", + "percent": percent + }), + ); } PlayerCliCmd::Repeat(mode) => { let s = match mode { @@ -1273,26 +1367,24 @@ pub fn emit_player_cli_cmd(app: &AppHandle, cmd: PlayerCliCmd) { RepeatCliMode::All => "all", RepeatCliMode::One => "one", }; - let _ = app.emit("cli:set-repeat", s); - } - PlayerCliCmd::Mute => { - let _ = app.emit("cli:mute", ()); - } - PlayerCliCmd::Unmute => { - let _ = app.emit("cli:unmute", ()); - } - PlayerCliCmd::StarCurrent => { - let _ = app.emit("cli:star-current", true); - } - PlayerCliCmd::UnstarCurrent => { - let _ = app.emit("cli:star-current", false); + emit_cli_player_command( + app, + serde_json::json!({ + "command": "set-repeat", + "mode": s + }), + ); } PlayerCliCmd::Rating { stars } => { - let _ = app.emit("cli:set-rating-current", stars); - } - PlayerCliCmd::ReloadPlayer => { - let _ = app.emit("cli:reload-player", ()); + emit_cli_player_command( + app, + serde_json::json!({ + "command": "set-rating-current", + "stars": stars + }), + ); } + PlayerCliCmd::NoArgCommand(_) => {} } } diff --git a/src-tauri/src/lib_commands/app_api/integration.rs b/src-tauri/src/lib_commands/app_api/integration.rs index ab57aa0e..f1e2e15e 100644 --- a/src-tauri/src/lib_commands/app_api/integration.rs +++ b/src-tauri/src/lib_commands/app_api/integration.rs @@ -29,15 +29,7 @@ pub(crate) fn register_global_shortcut( app.global_shortcut() .on_shortcut(parsed, move |app, _shortcut, event| { if event.state == ShortcutState::Pressed { - let event_name = match action.as_str() { - "play-pause" => "media:play-pause", - "next" => "media:next", - "prev" => "media:prev", - "volume-up" => "media:volume-up", - "volume-down" => "media:volume-down", - _ => return, - }; - let _ = app.emit(event_name, ()); + let _ = app.emit("shortcut:global-action", action.clone()); } }) .map_err(|e| e.to_string()) diff --git a/src/App.tsx b/src/App.tsx index a193bed1..0d9e2a71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,17 +81,12 @@ import { useAuthStore } from './store/authStore'; import { getMusicFolders, getSimilarSongs, - getSong, probeEntityRatingSupport, search as subsonicSearch, - setRating, - star, - unstar, } from './api/subsonic'; import { useOfflineStore } from './store/offlineStore'; import { initHotCachePrefetch } from './hotCachePrefetch'; import i18n from './i18n'; -import { playByOpaqueId } from './utils/playByOpaqueId'; import { switchActiveServer } from './utils/switchActiveServer'; import { usePlayerStore, @@ -104,15 +99,15 @@ import { useThemeStore } from './store/themeStore'; import { useThemeScheduler } from './hooks/useThemeScheduler'; import { useFontStore } from './store/fontStore'; import { useEqStore } from './store/eqStore'; -import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore'; +import { useKeybindingsStore, buildInAppBinding } from './store/keybindingsStore'; import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; import { useZipDownloadStore } from './store/zipDownloadStore'; import { usePreviewStore } from './store/previewStore'; +import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPlayerCommand, executeRuntimeAction, isGlobalShortcutActionId, isShortcutAction } from './config/shortcutActions'; +import { matchInAppShortcutAction } from './shortcuts/runtime'; import ZipDownloadOverlay from './components/ZipDownloadOverlay'; import PasteClipboardHandler from './components/PasteClipboardHandler'; -/** Volume before last `psysonic --player mute` (CLI only; in-memory). */ -let cliPremuteVolume: number | null = null; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed'; function readInitialSidebarCollapsed(): boolean { @@ -384,6 +379,12 @@ function AppShell() { setIsSidebarCollapsed(collapsed); }, []); + useEffect(() => { + const onToggleSidebar = () => setSidebarCollapsed(!isSidebarCollapsed); + window.addEventListener('psy:toggle-sidebar', onToggleSidebar); + return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar); + }, [isSidebarCollapsed, setSidebarCollapsed]); + const handleMouseMove = useCallback((e: MouseEvent) => { if (isDraggingQueue) { const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500)); @@ -758,9 +759,6 @@ function AppShell() { // Media key + tray event handler function TauriEventBridge() { const navigate = useNavigate(); - const togglePlay = usePlayerStore(s => s.togglePlay); - const next = usePlayerStore(s => s.next); - const previous = usePlayerStore(s => s.previous); // ZIP download progress events from Rust useEffect(() => { @@ -983,95 +981,8 @@ function TauriEventBridge() { }).catch(() => {}); } }).then(u => unsubs.push(u)); - listen('cli:play-id', async e => { - const id = typeof e.payload === 'string' ? e.payload.trim() : ''; - if (!id) return; - try { - await playByOpaqueId(id); - } catch (err) { - console.error('CLI play failed', err); - const notFound = err instanceof Error && err.message === 'play_by_id_not_found'; - showToast( - i18n.t('contextMenu.cliPlayIdNotFound', { - defaultValue: notFound - ? 'No song, album, or artist matches this id.' - : 'Could not start playback.', - }), - 5000, - 'error', - ); - } - }).then(u => unsubs.push(u)); - listen('cli:shuffle-queue', () => { - usePlayerStore.getState().shuffleQueue(); - }).then(u => unsubs.push(u)); - listen('cli:set-repeat', e => { - const m = typeof e.payload === 'string' ? e.payload : ''; - const mode = m === 'all' ? 'all' : m === 'one' ? 'one' : 'off'; - usePlayerStore.setState({ repeatMode: mode }); - }).then(u => unsubs.push(u)); - listen('cli:mute', () => { - const { volume, setVolume } = usePlayerStore.getState(); - if (volume > 0) cliPremuteVolume = volume; - setVolume(0); - }).then(u => unsubs.push(u)); - listen('cli:unmute', () => { - const restore = cliPremuteVolume ?? 0.8; - cliPremuteVolume = null; - usePlayerStore.getState().setVolume(restore); - }).then(u => unsubs.push(u)); - listen('cli:star-current', async e => { - const want = e.payload === true; - const track = usePlayerStore.getState().currentTrack; - if (!track) { - showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); - return; - } - try { - if (want) { - await star(track.id, 'song'); - usePlayerStore.getState().setStarredOverride(track.id, true); - } else { - await unstar(track.id, 'song'); - usePlayerStore.getState().setStarredOverride(track.id, false); - } - } catch (err) { - console.error('CLI star failed', err); - showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error'); - } - }).then(u => unsubs.push(u)); - listen('cli:set-rating-current', async e => { - const stars = e.payload; - if (typeof stars !== 'number' || Number.isNaN(stars) || stars < 0 || stars > 5) return; - const track = usePlayerStore.getState().currentTrack; - if (!track) { - showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); - return; - } - try { - await setRating(track.id, stars); - usePlayerStore.getState().setUserRatingOverride(track.id, stars); - } catch (err) { - console.error('CLI set rating failed', err); - } - }).then(u => unsubs.push(u)); - listen('cli:reload-player', async () => { - const store = usePlayerStore.getState(); - const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; - stop(); - resetAudioPause(); - await invoke('audio_stop').catch(() => {}); - if (currentTrack) { - try { - const fresh = await getSong(currentTrack.id); - const t = fresh ? songToTrack(fresh) : currentTrack; - playTrack(t, queue, true); - } catch { - playTrack(currentTrack, queue, true); - } - } else { - await initializeFromServerQueue(); - } + listen('cli:player-command', async e => { + await executeCliPlayerCommand({ payload: e.payload ?? {}, navigate }); }).then(u => unsubs.push(u)); return () => { unsubs.forEach(u => u()); @@ -1100,62 +1011,11 @@ function TauriEventBridge() { } const { bindings } = useKeybindingsStore.getState(); - const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState(); - - const action = (Object.entries(bindings) as [string, string | null][]) - .find(([, b]) => matchInAppBinding(e, b))?.[0]; + const action = matchInAppShortcutAction(e, { ...DEFAULT_IN_APP_BINDINGS, ...bindings }); if (!action) return; e.preventDefault(); - - // While a track preview is running, Spacebar pauses the preview rather - // than the main player (which is already paused under it). Skip / prev - // also cancel the preview so the main player resumes cleanly. - const previewing = usePreviewStore.getState().previewingId !== null; - - switch (action) { - case 'play-pause': - if (previewing) usePreviewStore.getState().stopPreview(); - else togglePlay(); - break; - case 'next': - if (previewing) usePreviewStore.getState().stopPreview(); - next(); - break; - case 'prev': - if (previewing) usePreviewStore.getState().stopPreview(); - previous(); - break; - case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break; - case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break; - case 'seek-forward': { - const s = usePlayerStore.getState(); - const dur = s.currentTrack?.duration ?? 0; - if (!dur) break; - seek(Math.min(1, (s.currentTime + 10) / dur)); - break; - } - case 'seek-backward': { - const s = usePlayerStore.getState(); - const dur = s.currentTrack?.duration ?? 0; - if (!dur) break; - seek(Math.max(0, (s.currentTime - 10) / dur)); - break; - } - case 'toggle-queue': toggleQueue(); break; - case 'open-folder-browser': - navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } }); - break; - case 'fullscreen-player': toggleFullscreen(); break; - case 'native-fullscreen': { - const win = getCurrentWindow(); - win.isFullscreen().then(fs => win.setFullscreen(!fs)); - break; - } - case 'open-mini-player': - invoke('open_mini_player').catch(() => {}); - break; - } + executeRuntimeAction(action, { navigate, previewPolicy: 'stop' }); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -1166,38 +1026,20 @@ function TauriEventBridge() { const unlisten: Array<() => void> = []; const setup = async () => { - // Hardware mediakeys are silently dropped while a preview is playing — - // matches the cucadmuh-flow expectation that headphone buttons don't - // accidentally interrupt or switch the previewed track. - const ifNoPreview = (fn: () => void) => () => { - if (usePreviewStore.getState().previewingId === null) fn(); - }; const handlers: Array<[string, () => void]> = [ - ['media:play-pause', ifNoPreview(() => togglePlay())], - ['media:play', ifNoPreview(() => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); })], - ['media:pause', ifNoPreview(() => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); })], - ['media:next', ifNoPreview(() => next())], - ['media:prev', ifNoPreview(() => previous())], - // Tray clicks are user-driven UI, so they fall through to the keyboard - // semantics: cancel the preview, then act. - ['tray:play-pause', () => { - if (usePreviewStore.getState().previewingId !== null) { - usePreviewStore.getState().stopPreview(); - } else { - togglePlay(); - } - }], - ['tray:next', () => { - if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview(); - next(); - }], - ['tray:previous', () => { - if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview(); - previous(); - }], - ['media:stop', ifNoPreview(() => usePlayerStore.getState().stop())], - ['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }], - ['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }], + // Hardware media controls should not interrupt active preview playback. + ['media:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'ignore' })], + ['media:play', () => executeRuntimeAction('play', { navigate, previewPolicy: 'ignore' })], + ['media:pause', () => executeRuntimeAction('pause', { navigate, previewPolicy: 'ignore' })], + ['media:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'ignore' })], + ['media:prev', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'ignore' })], + ['media:stop', () => executeRuntimeAction('stop', { navigate, previewPolicy: 'ignore' })], + ['media:volume-up', () => executeRuntimeAction('volume-up', { navigate, previewPolicy: 'ignore' })], + ['media:volume-down', () => executeRuntimeAction('volume-down', { navigate, previewPolicy: 'ignore' })], + // Tray clicks are explicit UI intent: stop preview first, then act. + ['tray:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'stop' })], + ['tray:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'stop' })], + ['tray:previous', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'stop' })], ]; for (const [event, handler] of handlers) { const u = await listen(event, handler); @@ -1205,6 +1047,30 @@ function TauriEventBridge() { unlisten.push(u); } + { + const u = await listen('shortcut:global-action', e => { + const action = e.payload; + if (!isGlobalShortcutActionId(action)) return; + executeRuntimeAction(action, { navigate, previewPolicy: 'ignore' }); + }); + if (cancelled) { u(); return; } + unlisten.push(u); + } + + { + const u = await listen<{ action: string; source?: string }>('shortcut:run-action', e => { + const action = e.payload?.action; + const source = e.payload?.source; + if (!action || !isShortcutAction(action)) return; + if (source === 'mini-window' && !canRunShortcutActionInMiniWindow(action)) return; + const previewPolicy = source === 'cli' ? 'ignore' : 'stop'; + executeRuntimeAction(action, { navigate, previewPolicy }); + }); + if (cancelled) { u(); return; } + unlisten.push(u); + } + + // Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress { const u = await listen('media:seek-relative', e => { @@ -1281,7 +1147,7 @@ function TauriEventBridge() { setup(); return () => { cancelled = true; unlisten.forEach(u => u()); }; - }, [togglePlay, next, previous]); + }, [navigate]); // `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically). useEffect(() => { diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 9d5092a2..bafd6e51 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -229,7 +229,10 @@ export default function MiniPlayer() { const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player']; if (matchInAppBinding(e, openMiniBinding)) { e.preventDefault(); - invoke('open_mini_player').catch(() => {}); + emit('shortcut:run-action', { + action: 'open-mini-player', + source: 'mini-window', + }).catch(() => {}); return; } @@ -245,11 +248,20 @@ export default function MiniPlayer() { if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); - emit('mini:control', 'toggle').catch(() => {}); + emit('shortcut:run-action', { + action: 'play-pause', + source: 'mini-window', + }).catch(() => {}); } else if (e.key === 'ArrowRight') { - emit('mini:control', 'next').catch(() => {}); + emit('shortcut:run-action', { + action: 'next', + source: 'mini-window', + }).catch(() => {}); } else if (e.key === 'ArrowLeft') { - emit('mini:control', 'prev').catch(() => {}); + emit('shortcut:run-action', { + action: 'prev', + source: 'mini-window', + }).catch(() => {}); } }; window.addEventListener('keydown', onKey); diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index ecf16d87..0322a8a8 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -203,6 +203,12 @@ export default function PlayerBar() { } }, []); + useEffect(() => { + const onToggleEqualizer = () => setEqOpen(v => !v); + window.addEventListener('psy:toggle-equalizer', onToggleEqualizer); + return () => window.removeEventListener('psy:toggle-equalizer', onToggleEqualizer); + }, []); + useEffect(() => { if (!utilityMenuOpen) return; const MENU_WIDTH = 238; diff --git a/src/config/shortcutActions.ts b/src/config/shortcutActions.ts new file mode 100644 index 00000000..37aae0e8 --- /dev/null +++ b/src/config/shortcutActions.ts @@ -0,0 +1,521 @@ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import i18n from '../i18n'; +import { getSong, setRating, star, unstar } from '../api/subsonic'; +import { songToTrack, usePlayerStore } from '../store/playerStore'; +import { usePreviewStore } from '../store/previewStore'; +import { useLyricsStore } from '../store/lyricsStore'; +import { showToast } from '../utils/toast'; +import { playByOpaqueId } from '../utils/playByOpaqueId'; + +export type TranslateLike = (key: string, options?: any) => string; + +type ShortcutSlot = { defaultBinding: string | null; hidden?: boolean }; + +type ActionContext = { + navigate: (to: string, options?: any) => void; + previewPolicy: 'stop' | 'ignore'; +}; + +type CliContext = { + navigate: (to: string, options?: any) => void; + payload: any; +}; + +let cliPremuteVolume: number | null = null; + +type ShortcutActionMeta = { + getLabel: (t: TranslateLike) => string; + inApp?: ShortcutSlot; + global?: ShortcutSlot; + runInMiniWindow: boolean; + run: (ctx: ActionContext) => void; + cli?: { verb: string; description: string; command?: string }; +}; + +const withPreviewPolicy = ( + action: 'play' | 'pause' | 'stop' | 'play-pause' | 'next' | 'prev', + options: ActionContext, + fn: () => void +) => { + const previewing = usePreviewStore.getState().previewingId !== null; + if (previewing && options.previewPolicy === 'ignore') return; + if (previewing && options.previewPolicy === 'stop') { + usePreviewStore.getState().stopPreview(); + } + fn(); +}; + +function focusLiveSearchInput(): boolean { + const input = document.getElementById('live-search-input') as HTMLInputElement | null; + if (!input) return false; + input.focus(); + input.select(); + return true; +} + + +export const SHORTCUT_ACTION_REGISTRY = { + 'play': { + getLabel: t => t('settings.shortcutPlayPause'), + runInMiniWindow: false, + run: ({ previewPolicy }) => withPreviewPolicy('play', { navigate: () => {}, previewPolicy }, () => { + const state = usePlayerStore.getState(); + if (!state.isPlaying) state.resume(); + }), + cli: { verb: 'play', description: 'play' }, + }, + 'pause': { + getLabel: t => t('settings.shortcutPlayPause'), + runInMiniWindow: false, + run: ({ previewPolicy }) => withPreviewPolicy('pause', { navigate: () => {}, previewPolicy }, () => { + const state = usePlayerStore.getState(); + if (state.isPlaying) state.pause(); + }), + cli: { verb: 'pause', description: 'pause' }, + }, + 'stop': { + getLabel: t => t('settings.shortcutPlayPause'), + runInMiniWindow: false, + run: ({ previewPolicy }) => withPreviewPolicy('stop', { navigate: () => {}, previewPolicy }, () => { + usePlayerStore.getState().stop(); + }), + cli: { verb: 'stop', description: 'stop' }, + }, + 'play-pause': { + getLabel: t => t('settings.shortcutPlayPause'), + inApp: { defaultBinding: 'Space' }, + global: { defaultBinding: null }, + runInMiniWindow: true, + run: ({ previewPolicy }) => withPreviewPolicy('play-pause', { navigate: () => {}, previewPolicy }, () => { + usePlayerStore.getState().togglePlay(); + }), + }, + next: { + getLabel: t => t('settings.shortcutNext'), + inApp: { defaultBinding: null }, + global: { defaultBinding: null }, + runInMiniWindow: true, + run: ({ previewPolicy }) => withPreviewPolicy('next', { navigate: () => {}, previewPolicy }, () => { + usePlayerStore.getState().next(); + }), + cli: { verb: 'next', description: 'next track' }, + }, + prev: { + getLabel: t => t('settings.shortcutPrev'), + inApp: { defaultBinding: null }, + global: { defaultBinding: null }, + runInMiniWindow: true, + run: ({ previewPolicy }) => withPreviewPolicy('prev', { navigate: () => {}, previewPolicy }, () => { + usePlayerStore.getState().previous(); + }), + cli: { verb: 'prev', description: 'previous track' }, + }, + 'volume-up': { + getLabel: t => t('settings.shortcutVolumeUp'), + inApp: { defaultBinding: null }, + global: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const state = usePlayerStore.getState(); + state.setVolume(Math.min(1, state.volume + 0.05)); + }, + }, + 'volume-down': { + getLabel: t => t('settings.shortcutVolumeDown'), + inApp: { defaultBinding: null }, + global: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const state = usePlayerStore.getState(); + state.setVolume(Math.max(0, state.volume - 0.05)); + }, + }, + 'seek-forward': { + getLabel: t => t('settings.shortcutSeekForward'), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const state = usePlayerStore.getState(); + const duration = state.currentTrack?.duration ?? 0; + if (!duration) return; + state.seek(Math.min(1, (state.currentTime + 10) / duration)); + }, + }, + 'seek-backward': { + getLabel: t => t('settings.shortcutSeekBackward'), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const state = usePlayerStore.getState(); + const duration = state.currentTrack?.duration ?? 0; + if (!duration) return; + state.seek(Math.max(0, (state.currentTime - 10) / duration)); + }, + }, + 'toggle-queue': { + getLabel: t => t('settings.shortcutToggleQueue'), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + usePlayerStore.getState().toggleQueue(); + }, + }, + 'open-folder-browser': { + getLabel: t => t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: ({ navigate }) => { + navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } }); + }, + }, + 'fullscreen-player': { + getLabel: t => t('settings.shortcutFullscreenPlayer'), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + usePlayerStore.getState().toggleFullscreen(); + }, + }, + 'native-fullscreen': { + getLabel: t => t('settings.shortcutNativeFullscreen'), + inApp: { defaultBinding: 'F11' }, + runInMiniWindow: false, + run: () => { + const win = getCurrentWindow(); + win.isFullscreen().then(fs => win.setFullscreen(!fs)); + }, + }, + 'open-mini-player': { + getLabel: t => t('settings.shortcutOpenMiniPlayer'), + inApp: { defaultBinding: null }, + runInMiniWindow: true, + run: () => { + invoke('open_mini_player').catch(() => {}); + }, + }, + 'start-search': { + getLabel: t => t('settings.shortcutStartSearch', { defaultValue: 'Start a search' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: ({ navigate }) => { + if (focusLiveSearchInput()) return; + navigate('/'); + requestAnimationFrame(() => { + window.setTimeout(() => { focusLiveSearchInput(); }, 80); + }); + }, + }, + 'start-advanced-search': { + getLabel: t => t('settings.shortcutStartAdvancedSearch', { defaultValue: 'Start an advanced search' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: ({ navigate }) => { + navigate('/search/advanced'); + }, + }, + 'toggle-sidebar': { + getLabel: t => t('settings.shortcutToggleSidebar', { defaultValue: 'Toggle sidebar' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + window.dispatchEvent(new Event('psy:toggle-sidebar')); + }, + }, + 'mute-sound': { + getLabel: t => t('settings.shortcutMuteSound', { defaultValue: 'Mute sound' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const state = usePlayerStore.getState(); + if (state.volume <= 0) { + const restore = cliPremuteVolume ?? 0.8; + cliPremuteVolume = null; + state.setVolume(restore); + return; + } + cliPremuteVolume = state.volume; + state.setVolume(0); + }, + }, + 'toggle-equalizer': { + getLabel: t => t('settings.shortcutToggleEqualizer', { defaultValue: 'Open / Toggle Equalizer' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + window.dispatchEvent(new Event('psy:toggle-equalizer')); + }, + }, + 'toggle-repeat': { + getLabel: t => t('settings.shortcutToggleRepeat', { defaultValue: 'Toggle repeat' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + usePlayerStore.getState().toggleRepeat(); + }, + }, + 'open-now-playing': { + getLabel: t => t('settings.shortcutOpenNowPlaying', { defaultValue: 'Open "Now Playing"' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: ({ navigate }) => { + navigate('/now-playing'); + }, + }, + 'show-lyrics': { + getLabel: t => t('settings.shortcutShowLyrics', { defaultValue: 'Show lyrics' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const player = usePlayerStore.getState(); + player.setQueueVisible(true); + useLyricsStore.getState().showLyrics(); + }, + }, + 'favorite-current-track': { + getLabel: t => t('settings.shortcutFavoriteCurrentTrack', { defaultValue: 'Add current track to favorites' }), + inApp: { defaultBinding: null }, + runInMiniWindow: false, + run: () => { + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack', { defaultValue: 'Load a track first.' }), 5000, 'error'); + return; + } + star(track.id, 'song') + .then(() => usePlayerStore.getState().setStarredOverride(track.id, true)) + .catch(err => { + console.error('Favorite current track failed', err); + showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Could not add the track to favorites.' }), 5000, 'error'); + }); + }, + }, + 'open-help': { + getLabel: t => t('settings.shortcutOpenHelp', { defaultValue: 'Help' }), + inApp: { defaultBinding: 'F1', hidden: true }, + runInMiniWindow: false, + run: ({ navigate }) => { + navigate('/help'); + }, + }, + 'shuffle': { + getLabel: t => t('settings.shortcutNext'), + runInMiniWindow: false, + run: () => { + usePlayerStore.getState().shuffleQueue(); + }, + cli: { verb: 'shuffle', description: 'shuffle' }, + }, + 'mute': { + getLabel: t => t('settings.shortcutVolumeDown'), + runInMiniWindow: false, + run: () => { + const state = usePlayerStore.getState(); + if (state.volume > 0) cliPremuteVolume = state.volume; + state.setVolume(0); + }, + cli: { verb: 'mute', description: 'mute' }, + }, + 'unmute': { + getLabel: t => t('settings.shortcutVolumeUp'), + runInMiniWindow: false, + run: () => { + const restore = cliPremuteVolume ?? 0.8; + cliPremuteVolume = null; + usePlayerStore.getState().setVolume(restore); + }, + cli: { verb: 'unmute', description: 'unmute' }, + }, + 'star': { + getLabel: t => t('settings.shortcutPlayPause'), + runInMiniWindow: false, + run: () => { + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + star(track.id, 'song') + .then(() => usePlayerStore.getState().setStarredOverride(track.id, true)) + .catch(err => { + console.error('CLI star failed', err); + showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error'); + }); + }, + cli: { verb: 'star', description: 'star' }, + }, + 'unstar': { + getLabel: t => t('settings.shortcutPlayPause'), + runInMiniWindow: false, + run: () => { + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + unstar(track.id, 'song') + .then(() => usePlayerStore.getState().setStarredOverride(track.id, false)) + .catch(err => { + console.error('CLI star failed', err); + showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error'); + }); + }, + cli: { verb: 'unstar', description: 'unstar' }, + }, + 'reload': { + getLabel: t => t('settings.shortcutPlayPause'), + runInMiniWindow: false, + run: () => { + const store = usePlayerStore.getState(); + const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; + stop(); + resetAudioPause(); + invoke('audio_stop') + .catch(() => {}) + .then(async () => { + if (currentTrack) { + try { + const fresh = await getSong(currentTrack.id); + const t = fresh ? songToTrack(fresh) : currentTrack; + playTrack(t, queue, true); + } catch { + playTrack(currentTrack, queue, true); + } + } else { + await initializeFromServerQueue(); + } + }); + }, + cli: { verb: 'reload', description: 'reload' }, + }, +} as const satisfies Record; + +export type ShortcutAction = keyof typeof SHORTCUT_ACTION_REGISTRY; +export type KeyAction = { + [Action in ShortcutAction]: (typeof SHORTCUT_ACTION_REGISTRY)[Action] extends { inApp: ShortcutSlot } ? Action : never +}[ShortcutAction]; +export type GlobalAction = { + [Action in ShortcutAction]: (typeof SHORTCUT_ACTION_REGISTRY)[Action] extends { global: ShortcutSlot } ? Action : never +}[ShortcutAction]; + +export function isShortcutAction(action: string): action is ShortcutAction { + return action in SHORTCUT_ACTION_REGISTRY; +} + +export function isGlobalShortcutActionId(action: string): action is GlobalAction { + return isShortcutAction(action) && 'global' in SHORTCUT_ACTION_REGISTRY[action]; +} + +export function canRunShortcutActionInMiniWindow(action: ShortcutAction): boolean { + return SHORTCUT_ACTION_REGISTRY[action].runInMiniWindow; +} + +export type RuntimeAction = ShortcutAction; + +export function executeRuntimeAction(action: RuntimeAction, ctx: ActionContext): void { + SHORTCUT_ACTION_REGISTRY[action].run(ctx); +} + +const CLI_NO_ARG_ACTIONS = Object.entries(SHORTCUT_ACTION_REGISTRY) + .flatMap(([id, def]) => { + if (!('cli' in def)) return []; + const cli = def.cli as { command?: string }; + return [{ command: cli.command ?? id, action: id as ShortcutAction }]; + }); + +export function executeCliPlayerCommand(ctx: CliContext): void | Promise { + const command = typeof ctx.payload?.command === 'string' ? ctx.payload.command : ''; + if (!command) return; + + const mapped = CLI_NO_ARG_ACTIONS.find(it => it.command === command); + if (mapped) { + executeRuntimeAction(mapped.action, { navigate: ctx.navigate, previewPolicy: 'ignore' }); + return; + } + if (command === 'play-id') { + const id = typeof ctx.payload.id === 'string' ? ctx.payload.id.trim() : ''; + if (!id) return; + return playByOpaqueId(id).catch(err => { + console.error('CLI play failed', err); + const notFound = err instanceof Error && err.message === 'play_by_id_not_found'; + showToast( + i18n.t('contextMenu.cliPlayIdNotFound', { + defaultValue: notFound + ? 'No song, album, or artist matches this id.' + : 'Could not start playback.', + }), + 5000, + 'error', + ); + }); + } + if (command === 'seek-relative') { + const delta = Number(ctx.payload.deltaSecs); + if (!Number.isFinite(delta)) return; + const state = usePlayerStore.getState(); + const duration = state.currentTrack?.duration; + if (!duration) return; + state.seek(Math.max(0, state.currentTime + delta) / duration); + return; + } + if (command === 'set-volume') { + const p = Number(ctx.payload.percent); + if (!Number.isFinite(p)) return; + usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100))); + return; + } + if (command === 'set-repeat') { + const modeRaw = typeof ctx.payload.mode === 'string' ? ctx.payload.mode : ''; + const mode = modeRaw === 'all' ? 'all' : modeRaw === 'one' ? 'one' : 'off'; + usePlayerStore.setState({ repeatMode: mode }); + return; + } + if (command === 'set-rating-current') { + const stars = Number(ctx.payload.stars); + if (!Number.isFinite(stars) || stars < 0 || stars > 5) return; + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + return setRating(track.id, stars) + .then(() => { + usePlayerStore.getState().setUserRatingOverride(track.id, stars); + }) + .catch(err => console.error('CLI set rating failed', err)); + } + // no-op for unknown command +} + +const ALL_IN_APP_SHORTCUT_ACTIONS = (Object.keys(SHORTCUT_ACTION_REGISTRY) as ShortcutAction[]) + .filter((action): action is KeyAction => 'inApp' in SHORTCUT_ACTION_REGISTRY[action]) + .map(action => { + const inApp = SHORTCUT_ACTION_REGISTRY[action].inApp as ShortcutSlot; + return { + id: action, + getLabel: SHORTCUT_ACTION_REGISTRY[action].getLabel, + defaultBinding: inApp.defaultBinding, + hidden: inApp.hidden === true, + }; + }); + +export const IN_APP_SHORTCUT_ACTIONS = ALL_IN_APP_SHORTCUT_ACTIONS.filter(action => !action.hidden); + +export const GLOBAL_SHORTCUT_ACTIONS = (Object.keys(SHORTCUT_ACTION_REGISTRY) as ShortcutAction[]) + .filter((action): action is GlobalAction => 'global' in SHORTCUT_ACTION_REGISTRY[action]) + .map(action => ({ + id: action, + getLabel: SHORTCUT_ACTION_REGISTRY[action].getLabel, + defaultBinding: SHORTCUT_ACTION_REGISTRY[action].global.defaultBinding, + })); + +export const DEFAULT_IN_APP_BINDINGS = Object.fromEntries( + ALL_IN_APP_SHORTCUT_ACTIONS.map(action => [action.id, action.defaultBinding]) +) as Record; + +export const DEFAULT_GLOBAL_SHORTCUTS: Partial> = {}; +for (const action of GLOBAL_SHORTCUT_ACTIONS) { + if (action.defaultBinding !== null) { + DEFAULT_GLOBAL_SHORTCUTS[action.id] = action.defaultBinding; + } +} diff --git a/src/locales/en.ts b/src/locales/en.ts index d3864f1a..a770f8ed 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -844,6 +844,16 @@ export const enTranslation = { shortcutFullscreenPlayer: 'Fullscreen player', shortcutNativeFullscreen: 'Native fullscreen', shortcutOpenMiniPlayer: 'Open mini player', + shortcutStartSearch: 'Start a search', + shortcutStartAdvancedSearch: 'Start an advanced search', + shortcutToggleSidebar: 'Toggle Sidebar', + shortcutMuteSound: 'Mute sound', + shortcutToggleEqualizer: 'Open / Toggle Equalizer', + shortcutToggleRepeat: 'Toggle Repeat', + shortcutOpenNowPlaying: 'Open "Now Playing"', + shortcutShowLyrics: 'Show Lyrics', + shortcutFavoriteCurrentTrack: 'Add current track to favorites', + shortcutOpenHelp: 'Help', playbackTitle: 'Playback', replayGain: 'Replay Gain', replayGainDesc: 'Normalize track volume using ReplayGain metadata', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 502f2562..06233c18 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -45,6 +45,7 @@ import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; +import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../config/shortcutActions'; import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; import { effectiveLoudnessPreAnalysisAttenuationDb, @@ -3898,20 +3899,8 @@ export default function Settings() { >
- {([ - ['play-pause', t('settings.shortcutPlayPause')], - ['next', t('settings.shortcutNext')], - ['prev', t('settings.shortcutPrev')], - ['volume-up', t('settings.shortcutVolumeUp')], - ['volume-down', t('settings.shortcutVolumeDown')], - ['seek-forward', t('settings.shortcutSeekForward')], - ['seek-backward', t('settings.shortcutSeekBackward')], - ['toggle-queue', t('settings.shortcutToggleQueue')], - ['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })], - ['fullscreen-player', t('settings.shortcutFullscreenPlayer')], - ['native-fullscreen', t('settings.shortcutNativeFullscreen')], - ['open-mini-player', t('settings.shortcutOpenMiniPlayer')], - ] as [KeyAction, string][]).map(([action, label]) => { + {IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { + const label = getLabel(t); const bound = kb.bindings[action]; const isListening = listeningFor === action; return ( @@ -3994,13 +3983,8 @@ export default function Settings() { >
- {([ - ['play-pause', t('settings.shortcutPlayPause')], - ['next', t('settings.shortcutNext')], - ['prev', t('settings.shortcutPrev')], - ['volume-up', t('settings.shortcutVolumeUp')], - ['volume-down', t('settings.shortcutVolumeDown')], - ] as [GlobalAction, string][]).map(([action, label]) => { + {GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { + const label = getLabel(t); const bound = gs.shortcuts[action] ?? null; const isListening = listeningForGlobal === action; return ( diff --git a/src/shortcuts/runtime.ts b/src/shortcuts/runtime.ts new file mode 100644 index 00000000..ddcb07dc --- /dev/null +++ b/src/shortcuts/runtime.ts @@ -0,0 +1,10 @@ +import { matchInAppBinding, type Bindings } from '../store/keybindingsStore'; +import type { KeyAction } from '../config/shortcutActions'; + +export function matchInAppShortcutAction( + event: KeyboardEvent, + bindings: Bindings +): KeyAction | null { + return (Object.entries(bindings) as [KeyAction, string | null][]) + .find(([, binding]) => matchInAppBinding(event, binding))?.[0] ?? null; +} diff --git a/src/store/globalShortcutsStore.ts b/src/store/globalShortcutsStore.ts index 4a7d0ebd..b24e133f 100644 --- a/src/store/globalShortcutsStore.ts +++ b/src/store/globalShortcutsStore.ts @@ -2,8 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { MODIFIER_KEY_CODES, formatBinding } from './keybindingsStore'; - -export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down'; +import { DEFAULT_GLOBAL_SHORTCUTS, isGlobalShortcutActionId, type GlobalAction } from '../config/shortcutActions'; /** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */ export function buildGlobalShortcut(e: KeyboardEvent): string | null { @@ -39,7 +38,7 @@ interface GlobalShortcutsState { export const useGlobalShortcutsStore = create()( persist( (set, get) => ({ - shortcuts: {}, + shortcuts: { ...DEFAULT_GLOBAL_SHORTCUTS }, setShortcut: async (action, shortcut) => { const prev = get().shortcuts[action]; @@ -67,6 +66,7 @@ export const useGlobalShortcutsStore = create()( _registerAllCalled = true; const { shortcuts } = get(); for (const [action, shortcut] of Object.entries(shortcuts)) { + if (!isGlobalShortcutActionId(action)) continue; if (shortcut) { try { await invoke('register_global_shortcut', { shortcut, action }); @@ -84,9 +84,11 @@ export const useGlobalShortcutsStore = create()( try { await invoke('unregister_global_shortcut', { shortcut }); } catch {} } } - set({ shortcuts: {} }); + set({ shortcuts: { ...DEFAULT_GLOBAL_SHORTCUTS } }); }, }), { name: 'psysonic_global_shortcuts' } ) ); + +export type { GlobalAction } from '../config/shortcutActions'; diff --git a/src/store/keybindingsStore.ts b/src/store/keybindingsStore.ts index d52dba5b..23ee89da 100644 --- a/src/store/keybindingsStore.ts +++ b/src/store/keybindingsStore.ts @@ -1,19 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; - -export type KeyAction = - | 'play-pause' - | 'next' - | 'prev' - | 'volume-up' - | 'volume-down' - | 'seek-forward' - | 'seek-backward' - | 'toggle-queue' - | 'open-folder-browser' - | 'fullscreen-player' - | 'native-fullscreen' - | 'open-mini-player'; +import { DEFAULT_IN_APP_BINDINGS, type KeyAction } from '../config/shortcutActions'; /** Physical keys only — ignore for binding capture */ export const MODIFIER_KEY_CODES = [ @@ -24,20 +11,17 @@ export const MODIFIER_KEY_CODES = [ // key = action, value = plain e.code ("Space", "KeyN") or chord "ctrl+shift+KeyN", null = unbound export type Bindings = Record; -export const DEFAULT_BINDINGS: Bindings = { - 'play-pause': 'Space', - 'next': null, - 'prev': null, - 'volume-up': null, - 'volume-down': null, - 'seek-forward': null, - 'seek-backward': null, - 'toggle-queue': null, - 'open-folder-browser': null, - 'fullscreen-player': null, - 'native-fullscreen': 'F11', - 'open-mini-player': null, -}; +export const DEFAULT_BINDINGS: Bindings = { ...DEFAULT_IN_APP_BINDINGS }; +export type { KeyAction } from '../config/shortcutActions'; + +function normalizeBindings( + bindings: Partial> | undefined +): Bindings { + return { + ...DEFAULT_BINDINGS, + ...(bindings ?? {}), + } as Bindings; +} interface KeybindingsState { bindings: Bindings; @@ -81,12 +65,18 @@ export function matchInAppBinding(e: KeyboardEvent, binding: string | null): boo export const useKeybindingsStore = create()( persist( (set) => ({ - bindings: { ...DEFAULT_BINDINGS }, + bindings: normalizeBindings(undefined), setBinding: (action, binding) => set(s => ({ bindings: { ...s.bindings, [action]: binding } })), - resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }), + resetToDefaults: () => set({ bindings: normalizeBindings(undefined) }), }), - { name: 'psysonic_keybindings' } + { + name: 'psysonic_keybindings', + onRehydrateStorage: () => state => { + if (!state) return; + state.bindings = normalizeBindings(state.bindings); + }, + } ) );