mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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).
This commit is contained in:
+173
-81
@@ -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<String> {
|
||||
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<String> {
|
||||
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<CliActionRegistryEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut current_action_id: Option<String> = 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<CliActionRegistryEntry> {
|
||||
static ENTRIES: OnceLock<Vec<CliActionRegistryEntry>> = 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 <action>");
|
||||
for entry in cli_action_registry_entries() {
|
||||
eprintln!(
|
||||
" {program} [--quiet|-q] --player {:<14} {}",
|
||||
entry.verb, entry.description
|
||||
);
|
||||
}
|
||||
eprintln!(" {program} [--quiet|-q] --player play <id> Track, album, or artist id (artist → shuffled library).");
|
||||
eprintln!(" {program} [--quiet|-q] --player seek <seconds> 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 <device-id|default>\n");
|
||||
@@ -879,11 +970,9 @@ fn parse_repeat_mode(arg: &str) -> Option<RepeatCliMode> {
|
||||
|
||||
fn parse_player_cli_at(args: &[String], pos: usize) -> Option<PlayerCliCmd> {
|
||||
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<PlayerCliCmd> {
|
||||
}
|
||||
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<PlayerCliCmd> {
|
||||
}
|
||||
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<PlayerCliCmd> {
|
||||
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<R: Runtime>(app: &AppHandle<R>, payload: serde_json::Value) {
|
||||
let _ = app.emit("cli:player-command", payload);
|
||||
}
|
||||
|
||||
pub fn emit_player_cli_cmd<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
+51
-185
@@ -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<string>('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<string>('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<boolean>('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<number>('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<any>('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<string>('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<number>('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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, ShortcutActionMeta>;
|
||||
|
||||
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<void> {
|
||||
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<KeyAction, string | null>;
|
||||
|
||||
export const DEFAULT_GLOBAL_SHORTCUTS: Partial<Record<GlobalAction, string>> = {};
|
||||
for (const action of GLOBAL_SHORTCUT_ACTIONS) {
|
||||
if (action.defaultBinding !== null) {
|
||||
DEFAULT_GLOBAL_SHORTCUTS[action.id] = action.defaultBinding;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+5
-21
@@ -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() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['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() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<GlobalShortcutsState>()(
|
||||
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<GlobalShortcutsState>()(
|
||||
_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<GlobalShortcutsState>()(
|
||||
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';
|
||||
|
||||
@@ -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<KeyAction, string | null>;
|
||||
|
||||
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<Record<KeyAction, string | null>> | 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<KeybindingsState>()(
|
||||
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);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user