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); + }, + } ) );