mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(cli): relative volume and quieter CLI startup (#1238)
This commit is contained in:
@@ -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)**
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user