feat(cli): relative volume and quieter CLI startup (#1238)

This commit is contained in:
cucadmuh
2026-07-05 14:57:39 +03:00
committed by GitHub
parent f48089e222
commit 9557f8e10a
11 changed files with 221 additions and 42 deletions
+7
View File
@@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes performers from the local artist index (featured/guest credits). Star filter works in both modes; the choice persists across app restarts like **Show artist images**.
* Letter bucket filter (`A``Z`, `#`, `OTHER`) runs in local SQL instead of scanning catalog chunks client-side, so late-alphabet picks load promptly on large libraries.
### CLI — relative volume and quieter scripting output
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1238](https://github.com/Psychotoxical/psysonic/pull/1238)**
* `psysonic --player volume +5` / `volume -10` adjust the current level by that many percent; `volume 80` still sets an absolute level (use `-q` before `--player` when the delta is negative so it is not parsed as a flag).
* CLI invocations no longer print WebKit/NVIDIA workaround notes on stderr; on Linux, remote `--player` forwarding runs before WebKit startup so helper processes exit with less noise.
### Square corners — sharp-edged cards and covers
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1215](https://github.com/Psychotoxical/psysonic/pull/1215)**
+1 -1
View File
@@ -149,7 +149,7 @@ case ${sub[1]} in
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100'
(( n == 1 )) && _message -e descriptions 'percent 0100, or ±N for relative change'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
+6 -1
View File
@@ -165,12 +165,17 @@ _psysonic_complete() {
rating)
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
;;
seek|volume)
seek)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
volume)
if (( n == 1 )); then
_psysonic_compreply_from_compgen '+5 +10 -5 -10 50' "$cur"
fi
;;
play)
if (( n == 1 )); then
_psysonic_compopt -o default
+13 -1
View File
@@ -117,7 +117,8 @@ pub fn print_help(program: &str) {
eprintln!(" Global flags (place before --player when needed):");
eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown).");
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!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.");
eprintln!(" Same for relative volume: {program} -q --player volume -5\n");
eprintln!(" Playback");
eprintln!(" {program} [--quiet|-q] --player <action>");
for entry in cli_action_registry_entries() {
@@ -129,6 +130,7 @@ pub fn print_help(program: &str) {
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 volume <±N> Relative change in percent, e.g. +5 or -10.");
eprintln!(" {program} [--quiet|-q] --player repeat off|all|one");
eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears).");
eprintln!();
@@ -457,6 +459,7 @@ pub fn describe_player_cli_cmd(cmd: &PlayerCliCmd) -> String {
PlayerCliCmd::PlayOpaqueId(id) => format!("play {id}"),
PlayerCliCmd::Seek { delta_secs } => format!("seek {delta_secs:+} s"),
PlayerCliCmd::Volume { percent } => format!("volume {percent}%"),
PlayerCliCmd::VolumeRelative { delta_percent } => format!("volume {delta_percent:+}%"),
PlayerCliCmd::Repeat(m) => match m {
RepeatCliMode::Off => "repeat off".into(),
RepeatCliMode::All => "repeat all".into(),
@@ -510,6 +513,15 @@ pub fn emit_player_cli_cmd<R: Runtime>(app: &AppHandle<R>, cmd: PlayerCliCmd) {
}),
);
}
PlayerCliCmd::VolumeRelative { delta_percent } => {
emit_cli_player_command(
app,
serde_json::json!({
"command": "volume-relative",
"deltaPercent": delta_percent
}),
);
}
PlayerCliCmd::Repeat(mode) => {
let s = match mode {
RepeatCliMode::Off => "off",
+72 -10
View File
@@ -20,6 +20,7 @@ pub enum PlayerCliCmd {
PlayOpaqueId(String),
Seek { delta_secs: i32 },
Volume { percent: u8 },
VolumeRelative { delta_percent: i32 },
Repeat(RepeatCliMode),
Rating { stars: u8 },
}
@@ -172,6 +173,17 @@ pub fn wants_logs(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "--logs")
}
/// True when argv uses any CLI surface (`--player`, `--info`, `--help`, …), not a plain GUI launch.
pub fn wants_cli_argv(args: &[String]) -> bool {
wants_version(args)
|| wants_help(args)
|| args.get(1).is_some_and(|a| a == "completions")
|| wants_info(args)
|| wants_logs(args)
|| wants_tail(args)
|| parse_cli_command(args).is_some()
}
pub fn wants_follow(args: &[String]) -> bool {
args.iter().skip(1).any(|a| a == "-f" || a == "--follow")
}
@@ -219,6 +231,24 @@ fn parse_repeat_mode(arg: &str) -> Option<RepeatCliMode> {
}
}
fn parse_volume_cli_arg(raw: &str) -> Option<PlayerCliCmd> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('+') || trimmed.starts_with('-') {
let delta_percent: i32 = trimmed.parse().ok()?;
return Some(PlayerCliCmd::VolumeRelative { delta_percent });
}
let v: i64 = trimmed.parse().ok()?;
if !(0..=100).contains(&v) {
return None;
}
Some(PlayerCliCmd::Volume {
percent: v as u8,
})
}
fn parse_player_cli_at(args: &[String], pos: usize) -> Option<PlayerCliCmd> {
let verb = args.get(pos + 1)?.as_str();
if let Some(entry) = cli_registry_entry_by_verb(verb).filter(|entry| entry.command == "play") {
@@ -251,16 +281,7 @@ fn parse_player_cli_at(args: &[String], pos: usize) -> Option<PlayerCliCmd> {
let delta_secs: i32 = raw.parse().ok()?;
Some(PlayerCliCmd::Seek { delta_secs })
}
"volume" => {
let raw = args.get(pos + 2)?;
let v: i64 = raw.parse().ok()?;
if !(0..=100).contains(&v) {
return None;
}
Some(PlayerCliCmd::Volume {
percent: v as u8,
})
}
"volume" => parse_volume_cli_arg(args.get(pos + 2)?),
_ => cli_registry_entry_by_verb(verb)
.map(|entry| PlayerCliCmd::NoArgCommand(entry.command.clone())),
}
@@ -338,3 +359,44 @@ pub fn parse_cli_command(args: &[String]) -> Option<CliCommand> {
_ => parse_player_cli_at(args, pos).map(CliCommand::Player),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_player(args: &[&str]) -> Option<PlayerCliCmd> {
let argv: Vec<String> = std::iter::once("psysonic")
.chain(args.iter().copied())
.map(String::from)
.collect();
match parse_cli_command(&argv) {
Some(CliCommand::Player(cmd)) => Some(cmd),
_ => None,
}
}
#[test]
fn volume_absolute_without_sign() {
assert_eq!(
parse_player(&["--player", "volume", "50"]),
Some(PlayerCliCmd::Volume { percent: 50 })
);
}
#[test]
fn volume_relative_with_sign() {
assert_eq!(
parse_player(&["--player", "volume", "+5"]),
Some(PlayerCliCmd::VolumeRelative { delta_percent: 5 })
);
assert_eq!(
parse_player(&["-q", "--player", "volume", "-10"]),
Some(PlayerCliCmd::VolumeRelative { delta_percent: -10 })
);
}
#[test]
fn volume_rejects_out_of_range_absolute() {
assert_eq!(parse_player(&["--player", "volume", "101"]), None);
}
}
-16
View File
@@ -342,22 +342,6 @@ pub fn run() {
#[cfg(target_os = "windows")]
set_app_user_model_id();
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")]
{
let argv: Vec<String> = std::env::args().collect();
if crate::cli::parse_cli_command(&argv).is_some() {
match crate::cli::linux_try_forward_player_cli_secondary(&argv) {
Ok(crate::cli::LinuxPlayerForwardResult::Forwarded) => std::process::exit(0),
Ok(crate::cli::LinuxPlayerForwardResult::ContinueStartup) => {}
Err(msg) => {
crate::app_eprintln!("NOT OK: {msg}");
std::process::exit(1);
}
}
}
}
let (audio_engine, _audio_thread) = audio::create_engine();
let builder = tauri::Builder::default()
+58 -11
View File
@@ -8,7 +8,20 @@ use webkit2gtk_nvidia_quirk::{
};
#[cfg(target_os = "linux")]
fn apply_linux_webkit_nvidia_quirk() {
fn apply_nv_workaround_kind_silent(kind: WorkaroundKind) {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer => {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
WorkaroundKind::DisableNvExplicitSync => {
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
}
}
}
#[cfg(target_os = "linux")]
fn apply_linux_webkit_nvidia_quirk(silent: bool) {
if std::env::var("PSYSONIC_WEBKIT_GPU_ACCEL").is_ok() {
return;
}
@@ -25,7 +38,19 @@ fn apply_linux_webkit_nvidia_quirk() {
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
matches!(s.split(',').next().map(str::trim), Some("x11"))
});
if silent {
if forced_x11_gdk {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer
| WorkaroundKind::DisableNvExplicitSync => {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
} else {
apply_nv_workaround_kind_silent(kind);
}
} else if forced_x11_gdk {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
@@ -51,18 +76,26 @@ fn apply_pipewire_latency() {
}
}
#[cfg(target_os = "linux")]
fn try_forward_linux_cli_player_argv(args: &[String]) {
use psysonic_lib::cli::{linux_try_forward_player_cli_secondary, parse_cli_command, LinuxPlayerForwardResult};
if parse_cli_command(args).is_none() {
return;
}
match linux_try_forward_player_cli_secondary(args) {
Ok(LinuxPlayerForwardResult::Forwarded) => std::process::exit(0),
Ok(LinuxPlayerForwardResult::ContinueStartup) => {}
Err(msg) => {
psysonic_lib::app_eprintln!("NOT OK: {msg}");
std::process::exit(1);
}
}
}
fn main() {
// Linux audio: cap the pipewire-alsa client latency so play/pause/seek/volume
// respond promptly (issue #862). Must run before any audio stream is opened.
#[cfg(target_os = "linux")]
apply_pipewire_latency();
// Linux GTK/WebKit: `webkit2gtk-nvidia-quirk` (skipped when `PSYSONIC_WEBKIT_GPU_ACCEL` is set).
// Forced `GDK_BACKEND=x11` uses the X11-only mitigation path — see `apply_linux_webkit_nvidia_quirk`.
#[cfg(target_os = "linux")]
apply_linux_webkit_nvidia_quirk();
let args: Vec<String> = std::env::args().collect();
if psysonic_lib::cli::wants_version(&args) {
psysonic_lib::cli::print_version();
return;
@@ -87,5 +120,19 @@ fn main() {
std::process::exit(2);
}
#[cfg(target_os = "linux")]
try_forward_linux_cli_player_argv(&args);
// Linux audio: cap the pipewire-alsa client latency so play/pause/seek/volume
// respond promptly (issue #862). Must run before any audio stream is opened.
#[cfg(target_os = "linux")]
apply_pipewire_latency();
// Linux GTK/WebKit: `webkit2gtk-nvidia-quirk` (skipped when `PSYSONIC_WEBKIT_GPU_ACCEL` is set).
// Forced `GDK_BACKEND=x11` uses the X11-only mitigation path — see `apply_linux_webkit_nvidia_quirk`.
// Skip the crate's stderr notes when argv is a CLI invocation (scripting noise).
#[cfg(target_os = "linux")]
apply_linux_webkit_nvidia_quirk(psysonic_lib::cli::wants_cli_argv(&args));
psysonic_lib::run();
}
+1
View File
@@ -184,6 +184,7 @@ const CONTRIBUTOR_ENTRIES = [
'Playlists — batch playlist writes past the GET URL limit (add >341 tracks), in-memory membership cache for fast dedup, offline↔playlist layering detangle (PR #1235)',
'Queue — resolve thin-state rows off the visible range so off-window items stop rendering as "…" placeholders (desktop panel, mobile drawer, fullscreen up-next) (PR #1236)',
'Artists browse — case-insensitive Cyrillic/non-ASCII name search when local index is enabled (PR #1237)',
'CLI — relative volume via signed `volume` argument (+/ percent delta); suppress WebKit NVIDIA stderr notes on CLI argv; faster Linux CLI forward before WebKit init (PR #1238)',
],
},
{
+1 -1
View File
@@ -32,7 +32,7 @@
// • cli? — exposes the action to `psysonic --player <verb>`. No-arg CLI
// verbs are auto-collected and dispatched by
// `executeCliPlayerCommand`; arg-carrying commands (play-id,
// seek-relative, set-volume, set-repeat, set-rating-current)
// seek-relative, set-volume, volume-relative, set-repeat, set-rating-current)
// are handled explicitly there.
// • run(ctx) — the handler. `ctx.previewPolicy` ('stop' | 'ignore') decides
// whether an active track-preview is interrupted: media keys
+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const player = {
volume: 0.5,
setVolume: vi.fn((v: number) => {
player.volume = v;
}),
};
return { player };
});
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: { getState: () => hoisted.player },
}));
import { executeCliPlayerCommand } from '@/config/shortcutDispatch';
const navigate = vi.fn();
beforeEach(() => {
hoisted.player.volume = 0.5;
hoisted.player.setVolume.mockClear();
navigate.mockClear();
});
describe('executeCliPlayerCommand volume-relative', () => {
it('raises volume by delta percent and clamps at 1', () => {
executeCliPlayerCommand({
payload: { command: 'volume-relative', deltaPercent: 10 },
navigate,
});
expect(hoisted.player.setVolume).toHaveBeenCalledWith(0.6);
});
it('lowers volume by delta percent and clamps at 0', () => {
hoisted.player.volume = 0.03;
executeCliPlayerCommand({
payload: { command: 'volume-relative', deltaPercent: -10 },
navigate,
});
expect(hoisted.player.setVolume).toHaveBeenCalledWith(0);
});
});
describe('executeCliPlayerCommand set-volume', () => {
it('sets absolute percent', () => {
executeCliPlayerCommand({
payload: { command: 'set-volume', percent: 40 },
navigate,
});
expect(hoisted.player.setVolume).toHaveBeenCalledWith(0.4);
});
});
+7
View File
@@ -76,6 +76,13 @@ export function executeCliPlayerCommand(ctx: CliContext): void | Promise<void> {
usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100)));
return;
}
if (command === 'volume-relative') {
const delta = Number(ctx.payload.deltaPercent);
if (!Number.isFinite(delta)) return;
const state = usePlayerStore.getState();
state.setVolume(Math.min(1, Math.max(0, state.volume + delta / 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';