diff --git a/CHANGELOG.md b/CHANGELOG.md index cc718730..fd324edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)** diff --git a/completions/_psysonic b/completions/_psysonic index 524ff917..041f8b16 100644 --- a/completions/_psysonic +++ b/completions/_psysonic @@ -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 0–100' + (( n == 1 )) && _message -e descriptions 'percent 0–100, or ±N for relative change' ;; play) (( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)' diff --git a/completions/psysonic.bash b/completions/psysonic.bash index 3332c6fe..c114ad8f 100644 --- a/completions/psysonic.bash +++ b/completions/psysonic.bash @@ -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 diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index f95f30b6..fc37f864 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -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 "); for entry in cli_action_registry_entries() { @@ -129,6 +130,7 @@ pub fn print_help(program: &str) { 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 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(app: &AppHandle, 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", diff --git a/src-tauri/src/cli/parse.rs b/src-tauri/src/cli/parse.rs index 6e408129..b843303b 100644 --- a/src-tauri/src/cli/parse.rs +++ b/src-tauri/src/cli/parse.rs @@ -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 { } } +fn parse_volume_cli_arg(raw: &str) -> Option { + 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 { 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 { 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 { _ => parse_player_cli_at(args, pos).map(CliCommand::Player), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_player(args: &[&str]) -> Option { + let argv: Vec = 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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 66c9d685..88ece959 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 = 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() diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 72de8962..452b409b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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 forced_x11_gdk { + 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 = 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(); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 7472d0d6..5d77b9cd 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/config/shortcutActions.ts b/src/config/shortcutActions.ts index b31b21fb..52c6b82c 100644 --- a/src/config/shortcutActions.ts +++ b/src/config/shortcutActions.ts @@ -32,7 +32,7 @@ // • cli? — exposes the action to `psysonic --player `. 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 diff --git a/src/config/shortcutDispatch.test.ts b/src/config/shortcutDispatch.test.ts new file mode 100644 index 00000000..38fe6d05 --- /dev/null +++ b/src/config/shortcutDispatch.test.ts @@ -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); + }); +}); diff --git a/src/config/shortcutDispatch.ts b/src/config/shortcutDispatch.ts index b5f26a80..17649d80 100644 --- a/src/config/shortcutDispatch.ts +++ b/src/config/shortcutDispatch.ts @@ -76,6 +76,13 @@ export function executeCliPlayerCommand(ctx: CliContext): void | Promise { 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';