From c75297fcf63064c04b739abf14f7e70f40c10320 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Wed, 15 Apr 2026 00:56:40 +0300 Subject: [PATCH 1/2] feat(cli): completions, library/audio commands, server switcher Embed bash and zsh completion scripts and add a `completions` subcommand. Extend the player CLI (library, audio device, instant mix) and surface `music_library` in snapshots. Add a shared active-server switcher in the header and locales; instant mix can reseed the queue from CLI and UI. --- completions/README.md | 36 ++ completions/_psysonic | 101 +++ completions/psysonic.bash | 111 ++++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/audio.rs | 19 +- src-tauri/src/cli.rs | 815 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 47 +- src-tauri/src/main.rs | 19 + src/App.tsx | 148 ++++- src/components/ConnectionIndicator.tsx | 148 ++++- src/components/ContextMenu.tsx | 7 +- src/locales/de.ts | 5 + src/locales/en.ts | 5 + src/locales/es.ts | 5 + src/locales/fr.ts | 5 + src/locales/nb.ts | 5 + src/locales/nl.ts | 5 + src/locales/ru.ts | 5 + src/locales/zh.ts | 5 + src/pages/Settings.tsx | 23 +- src/store/playerStore.ts | 20 +- src/styles/components.css | 18 + src/utils/switchActiveServer.ts | 30 + 24 files changed, 1533 insertions(+), 53 deletions(-) create mode 100644 completions/README.md create mode 100644 completions/_psysonic create mode 100644 completions/psysonic.bash create mode 100644 src-tauri/src/cli.rs create mode 100644 src/utils/switchActiveServer.ts diff --git a/completions/README.md b/completions/README.md new file mode 100644 index 00000000..b4c486f7 --- /dev/null +++ b/completions/README.md @@ -0,0 +1,36 @@ +# Shell completion for `psysonic` + +Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list. + +The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed). + +## zsh + +Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion: + +```sh +mkdir -p ~/.zsh/completions +ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic +fpath=(~/.zsh/completions $fpath) +autoload -Uz compinit && compinit +``` + +If you use a plugin manager, point its `fpath` at this repo’s `completions/` directory instead. + +## bash + +Source the script once (e.g. in `~/.bashrc`): + +```sh +source /path/to/psysonic/completions/psysonic.bash +``` + +Use this only under **bash** (including macOS’s `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh. + +## Device names after `audio-device set` + +Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts. + +## Folder ids after `library set` + +Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**. diff --git a/completions/_psysonic b/completions/_psysonic new file mode 100644 index 00000000..ff680d96 --- /dev/null +++ b/completions/_psysonic @@ -0,0 +1,101 @@ +#compdef psysonic +# Zsh completion for Psysonic CLI (see `psysonic --help`). + +_psysonic_audio_device_json() { + local f + if [[ -n $XDG_RUNTIME_DIR ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json" + [[ -r $f ]] && { print -r -- "$f"; return } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json" + [[ -r $f ]] && print -r -- "$f" +} + +_psysonic_audio_device_ids() { + command -v jq &>/dev/null || return + local jf + jf="$(_psysonic_audio_device_json)" || return + local -a devs + devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} ) + (( $#devs )) && compadd -a devs +} + +_psysonic_library_json() { + local f + if [[ -n $XDG_RUNTIME_DIR ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-library.json" + [[ -r $f ]] && { print -r -- "$f"; return } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-library.json" + [[ -r $f ]] && print -r -- "$f" +} + +_psysonic_library_folder_ids() { + command -v jq &>/dev/null || return + local jf + jf="$(_psysonic_library_json)" || return + local -a ids + ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} ) + (( $#ids )) && compadd -a ids +} + +_psysonic_globals() { + compadd -J options -X 'option' -- \ + --help --version --info --json --quiet --player completions +} + +integer i pidx=0 +for (( i = 2; i < CURRENT; i++ )); do + [[ ${words[i]} == --player ]] && pidx=i +done + +if (( pidx == 0 )); then + if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then + compadd help bash zsh + return + fi + _psysonic_globals + return +fi + +local -a sub +if (( pidx + 1 <= CURRENT - 1 )); then + sub=( ${words[pidx + 1,CURRENT - 1]} ) +else + sub=() +fi + +integer n=${#sub[@]} +if (( n == 0 )); then + compadd -J verbs -X 'player command' -- \ + next prev play pause seek volume audio-device library mix + return +fi + +case ${sub[1]} in + audio-device) + if (( n == 1 )); then + compadd list set + elif [[ ${sub[2]} == set ]] && (( n == 2 )); then + compadd default + _psysonic_audio_device_ids + fi + ;; + library) + if (( n == 1 )); then + compadd list set + elif [[ ${sub[2]} == set ]] && (( n == 2 )); then + compadd all + _psysonic_library_folder_ids + fi + ;; + mix) + (( n == 1 )) && compadd append new + ;; + seek) + (( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)' + ;; + volume) + (( n == 1 )) && _message -e descriptions 'percent 0–100' + ;; +esac diff --git a/completions/psysonic.bash b/completions/psysonic.bash new file mode 100644 index 00000000..44f4e507 --- /dev/null +++ b/completions/psysonic.bash @@ -0,0 +1,111 @@ +# bash completion for Psysonic (see `psysonic --help`). +# Install: source /path/to/completions/psysonic.bash +# Optional: jq + prior `psysonic --player audio-device list` for device name completion. +# +# Uses no `mapfile` so bash 3.2 (macOS default) works. + +_psysonic_compreply_from_compgen() { + # $1 = compgen -W word list, $2 = current word + COMPREPLY=() + local line + while IFS= read -r line; do + [[ -n $line ]] && COMPREPLY+=("$line") + done < <(compgen -W "$1" -- "$2") +} + +_psysonic_audio_device_json() { + local f + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json" + [[ -r $f ]] && { printf '%s' "$f"; return; } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json" + [[ -r $f ]] && printf '%s' "$f" +} + +_psysonic_library_json() { + local f + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-library.json" + [[ -r $f ]] && { printf '%s' "$f"; return; } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-library.json" + [[ -r $f ]] && printf '%s' "$f" +} + +_psysonic_complete() { + local cur + cur="${COMP_WORDS[COMP_CWORD]}" + + local i pidx=0 + for (( i = 1; i < COMP_CWORD; i++ )); do + [[ ${COMP_WORDS[i]} == --player ]] && pidx=$i + done + + if (( pidx == 0 )); then + if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then + _psysonic_compreply_from_compgen 'help bash zsh' "$cur" + return + fi + _psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur" + return + fi + + local -a sub=() + for (( i = pidx + 1; i < COMP_CWORD; i++ )); do + sub+=("${COMP_WORDS[i]}") + done + local n=${#sub[@]} + + if (( n == 0 )); then + _psysonic_compreply_from_compgen 'next prev play pause seek volume audio-device library mix' "$cur" + return + fi + + case ${sub[0]} in + audio-device) + if (( n == 1 )); then + _psysonic_compreply_from_compgen 'list set' "$cur" + elif [[ ${sub[1]} == set ]] && (( n == 2 )); then + COMPREPLY=() + local jf d + jf="$(_psysonic_audio_device_json)" + if [[ -n $jf ]] && command -v jq &>/dev/null; then + while IFS= read -r d; do + [[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d") + done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null) + fi + while IFS= read -r line; do + [[ -n $line ]] && COMPREPLY+=("$line") + done < <(compgen -W 'default' -- "$cur") + ((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null + fi + ;; + library) + if (( n == 1 )); then + _psysonic_compreply_from_compgen 'list set' "$cur" + elif [[ ${sub[1]} == set ]] && (( n == 2 )); then + COMPREPLY=() + local jf id line + jf="$(_psysonic_library_json)" + if [[ -n $jf ]] && command -v jq &>/dev/null; then + while IFS= read -r id; do + [[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id") + done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null) + fi + while IFS= read -r line; do + [[ -n $line ]] && COMPREPLY+=("$line") + done < <(compgen -W 'all' -- "$cur") + ((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null + fi + ;; + mix) + (( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur" + ;; + seek|volume) + (( n == 1 )) && compopt -o default && COMPREPLY=() + ;; + esac +} + +complete -F _psysonic_complete psysonic diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 037a1375..12b047fd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3615,6 +3615,7 @@ dependencies = [ "tokio", "url", "windows 0.58.0", + "zbus 5.14.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f6774b7..b7c38c8b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,9 @@ symphonia-adapter-libopus = "0.2.7" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { version = "5.9", default-features = false, features = ["blocking-api"] } + [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index e049ebe6..794e86fe 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -3026,6 +3026,17 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti Some(canon) } +/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance). +pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec { + let mut list = enumerate_output_device_names(); + if let Some(ref name) = *engine.selected_device.lock().unwrap() { + if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) { + list.push(name.clone()); + } + } + list +} + /// Returns the names of all available audio output devices on the current host. /// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to /// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean. @@ -3034,13 +3045,7 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti /// streaming) so the Settings dropdown still matches `audioOutputDevice`. #[tauri::command] pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec { - let mut list = enumerate_output_device_names(); - if let Some(ref name) = *state.selected_device.lock().unwrap() { - if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) { - list.push(name.clone()); - } - } - list + audio_list_devices_for_engine(&state) } /// Device id string for the host default output (matches an entry from `audio_list_devices` when present). diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs new file mode 100644 index 00000000..7b8d9543 --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,815 @@ +//! CLI surface for scripting / compositor bindings (e.g. Hyprland `exec`). + +// Bundled at compile time for `psysonic completions bash|zsh` (no extra files in packages). +const COMPLETIONS_BASH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../completions/psysonic.bash")); +const COMPLETIONS_ZSH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../completions/_psysonic")); + +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tauri::{AppHandle, Emitter, Runtime}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlayerCliCmd { + Next, + Prev, + Play, + Pause, + Seek { delta_secs: i32 }, + Volume { percent: u8 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MixCliMode { + Append, + New, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CliCommand { + Player(PlayerCliCmd), + AudioDeviceList, + /// `None` → follow host default output (same as Settings “system default”). + AudioDeviceSet(Option), + LibraryList, + /// `"all"` or a music-folder id from `library list`. + LibrarySet(String), + Mix(MixCliMode), +} + +pub fn wants_version(args: &[String]) -> bool { + args.iter() + .skip(1) + .any(|a| a == "--version" || a == "-V") +} + +pub fn wants_help(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--help" || a == "-h") +} + +pub fn wants_info(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--info") +} + +pub fn wants_info_json(args: &[String]) -> bool { + wants_info(args) && args.iter().skip(1).any(|a| a == "--json") +} + +pub fn wants_quiet(args: &[String]) -> bool { + args.iter() + .skip(1) + .any(|a| a == "--quiet" || a == "-q") +} + +/// Machine-readable output for `--json` with `audio-device list` or `library list`. +pub fn wants_cli_json_output(args: &[String]) -> bool { + args.iter().skip(1).any(|a| a == "--json") +} + +pub fn print_version() { + println!("{}", env!("CARGO_PKG_VERSION")); +} + +/// JSON snapshot path (written by the GUI process, read by `psysonic --info`). +pub fn cli_snapshot_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-snapshot.json"); + } + } + std::env::temp_dir().join("psysonic-cli-snapshot.json") +} + +pub fn cli_audio_device_response_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-audio-devices.json"); + } + } + std::env::temp_dir().join("psysonic-cli-audio-devices.json") +} + +pub fn cli_library_response_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-library.json"); + } + } + std::env::temp_dir().join("psysonic-cli-library.json") +} + +/// `psysonic completions …` — returns exit code when this argv should not start the GUI. +pub fn try_completions_dispatch(args: &[String]) -> Option { + if args.get(1).map(|s| s.as_str()) != Some("completions") { + return None; + } + let program = args.first().map(|s| s.as_str()).unwrap_or("psysonic"); + match args.get(2).map(|s| s.as_str()) { + None | Some("help") | Some("--help") | Some("-h") => { + print_completions_install_help(program); + Some(0) + } + Some("bash") => { + print!("{COMPLETIONS_BASH}"); + Some(0) + } + Some("zsh") => { + print!("{COMPLETIONS_ZSH}"); + Some(0) + } + Some(x) => { + eprintln!("NOT OK: unknown completions subcommand {x:?} (expected: bash, zsh, help)"); + Some(2) + } + } +} + +fn print_completions_install_help(program: &str) { + eprintln!( + "Psysonic embeds bash/zsh completion scripts in this binary.\n\ + \n\ + Bash — try once in this shell:\n\ + eval \"$({program} completions bash)\"\n\ + Or install:\n\ + mkdir -p ~/.local/share/psysonic\n\ + {program} completions bash > ~/.local/share/psysonic/psysonic.bash\n\ + echo '. ~/.local/share/psysonic/psysonic.bash' >> ~/.bashrc && source ~/.bashrc\n\ + \n\ + Zsh — install file then register (once in ~/.zshrc before compinit):\n\ + mkdir -p ~/.zsh/completions\n\ + {program} completions zsh > ~/.zsh/completions/_psysonic\n\ + fpath=(~/.zsh/completions $fpath)\n\ + autoload -Uz compinit && compinit\n\ + \n\ + Scripts only (stdout, for piping):\n\ + {program} completions bash\n\ + {program} completions zsh\n", + program = program, + ); +} + +pub fn write_cli_snapshot(payload: &Value) -> Result<(), String> { + let path = cli_snapshot_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(payload).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn print_help(program: &str) { + let version = env!("CARGO_PKG_VERSION"); + eprintln!("Psysonic {version}\n"); + eprintln!("── Start ──"); + eprintln!(" {program}"); + eprintln!(" {program} --version | -V Print version and exit."); + eprintln!(" {program} --help | -h Show this help.\n"); + eprintln!("── Shell completion (scripts are embedded in the binary) ──"); + eprintln!(" {program} completions How to enable tab completion in bash / zsh."); + eprintln!(" {program} completions bash Print bash completion script (stdout)."); + eprintln!(" {program} completions zsh Print zsh _psysonic script (stdout).\n"); + eprintln!("── Snapshot (saved play state / queue) ──"); + eprintln!(" Reads a JSON file written by the running app. Open the main window at least once."); + eprintln!(" {program} --info Human-readable summary."); + eprintln!(" {program} --info --json One JSON object on stdout."); + eprintln!(" Linux: exits with an error if the primary instance is not on the session D-Bus."); + eprintln!(" Windows / macOS: no D-Bus check; an empty or missing file means the UI has not"); + eprintln!(" published a snapshot yet.\n"); + eprintln!("── Remote commands (--player …) ──"); + eprintln!(" Require the main Psysonic process. Same flags on Linux, Windows, and macOS."); + eprintln!(" Linux: a second CLI process can forward over D-Bus without opening another window."); + eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); + 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` or `library list`: 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"); + eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); + eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent.\n"); + eprintln!(" Audio output"); + eprintln!(" {program} [--json] --player audio-device list"); + eprintln!(" {program} --player audio-device set \n"); + eprintln!(" Music library (Subsonic music folders for the active server)"); + eprintln!(" {program} [--json] --player library list"); + eprintln!(" {program} --player library set all | \n"); + eprintln!(" Instant mix (from the track that is currently loaded)"); + eprintln!(" {program} --player mix append"); + eprintln!(" {program} --player mix new\n"); + eprintln!("Exit: 0 on success. Errors print \"NOT OK: …\" on stderr with a non-zero status."); +} + +/// Wait for the webview to write `psysonic-cli-library.json` after `cli:library-list`. +fn read_library_cli_response_blocking(max_wait: Duration) -> String { + let path = cli_library_response_path(); + let deadline = Instant::now() + max_wait; + loop { + if let Ok(text) = std::fs::read_to_string(&path) { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + if v.get("folders").and_then(|x| x.as_array()).is_some() { + return text; + } + } + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(40)); + } + std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".into()) +} + +pub fn print_library_cli_stdout(text: &str, json_out: bool) { + if json_out { + println!("{}", text.trim()); + return; + } + if let Ok(v) = serde_json::from_str::(text) { + print_library_human(&v); + } else { + println!("{}", text.trim()); + } +} + +fn print_library_human(v: &Value) { + if let Some(sid) = v.get("active_server_id").and_then(|x| x.as_str()) { + println!("active_server_id: {sid}"); + } else { + println!("active_server_id: (none)"); + } + match v.get("selected").and_then(|x| x.as_str()) { + Some(s) => println!("selected: {s}"), + None => println!("selected: (unknown)"), + } + println!("folders:"); + if let Some(Value::Array(rows)) = v.get("folders") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } else { + println!(" (invalid JSON: missing folders array)"); + } +} + +pub fn write_library_cli_response(payload: &Value) -> Result<(), String> { + let path = cli_library_response_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(payload).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +fn tauri_identifier() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| { + let raw = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tauri.conf.json")); + let v: serde_json::Value = + serde_json::from_str(raw).expect("parse embedded tauri.conf.json"); + v["identifier"] + .as_str() + .expect("tauri.conf.json identifier") + .to_string() + }) + .as_str() +} + +fn single_instance_bus_name() -> String { + format!("{}.SingleInstance", tauri_identifier()) +} + +fn single_instance_object_path(dbus_name: &str) -> String { + let mut dbus_path = dbus_name.replace('.', "/").replace('-', "_"); + if !dbus_path.starts_with('/') { + dbus_path = format!("/{dbus_path}"); + } + dbus_path +} + +#[cfg(target_os = "linux")] +fn linux_bus_name_has_owner( + conn: &zbus::blocking::Connection, + bus_name: &str, +) -> Result { + let reply = conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "NameHasOwner", + &(bus_name,), + ) + .map_err(|e| format!("NameHasOwner: {e}"))?; + reply + .body() + .deserialize::() + .map_err(|e| format!("NameHasOwner reply: {e}")) +} + +/// Whether the main Psysonic instance holds the single-instance D-Bus name (Linux only). +#[cfg(target_os = "linux")] +pub fn linux_is_primary_instance_running() -> Result { + use zbus::blocking::Connection; + let conn = Connection::session().map_err(|e| format!("D-Bus session: {e}"))?; + let well_known = single_instance_bus_name(); + linux_bus_name_has_owner(&conn, &well_known) +} + +/// Print snapshot and `exit`. Used from `main` before `run()`. +pub fn run_info_and_exit(args: &[String]) -> ! { + let json_out = wants_info_json(args); + + #[cfg(target_os = "linux")] + { + match linux_is_primary_instance_running() { + Ok(true) => {} + Ok(false) => { + eprintln!("NOT OK: Psysonic is not running"); + std::process::exit(2); + } + Err(e) => { + eprintln!("NOT OK: {e}"); + std::process::exit(1); + } + } + } + + let path = cli_snapshot_path(); + let text = std::fs::read_to_string(&path).unwrap_or_default(); + let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + let empty = v.is_null() || v.as_object().map(|m| m.is_empty()).unwrap_or(true); + if empty { + eprintln!("NOT OK: no CLI snapshot yet — wait until the main window has loaded."); + std::process::exit(3); + } + + if json_out { + match serde_json::to_string(&v) { + Ok(line) => println!("{line}"), + Err(e) => { + eprintln!("NOT OK: {e}"); + std::process::exit(1); + } + } + } else { + print_info_human(&v); + } + std::process::exit(0); +} + +fn print_info_human(v: &Value) { + let o = v.as_object(); + let o = match o { + Some(m) => m, + None => { + println!("(snapshot is not a JSON object)"); + return; + } + }; + + let track = o.get("current_track").and_then(|x| x.as_object()); + println!("=== current_track ==="); + match track { + None => println!("(none)"), + Some(t) if t.is_empty() => println!("(none)"), + Some(t) => { + for (k, val) in sorted_kv(t) { + println!(" {k}: {}", value_inline(val)); + } + } + } + + println!("=== current_radio ==="); + match o.get("current_radio") { + None | Some(Value::Null) => println!("(none)"), + Some(Value::Object(m)) if m.is_empty() => println!("(none)"), + Some(Value::Object(m)) => { + for (k, val) in sorted_kv(m) { + println!(" {k}: {}", value_inline(val)); + } + } + Some(x) => println!(" {}", value_inline(x)), + } + + println!("=== music_library ==="); + match o.get("music_library").and_then(|x| x.as_object()) { + None => println!("(none)"), + Some(m) if m.is_empty() => println!("(none)"), + Some(m) => { + if let Some(v) = m.get("selected") { + println!(" selected: {}", value_inline(v)); + } + if let Some(v) = m.get("active_server_id") { + println!(" active_server_id: {}", value_inline(v)); + } + println!(" folders:"); + match m.get("folders").and_then(|x| x.as_array()) { + None => println!(" (none loaded)"), + Some(a) if a.is_empty() => println!(" (none loaded)"), + Some(rows) => { + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } + } + } + } + + println!("=== playback ==="); + for key in [ + "is_playing", + "current_time", + "volume", + "queue_index", + "queue_length", + ] { + if let Some(val) = o.get(key) { + println!(" {key}: {}", value_inline(val)); + } + } + + println!("=== queue ({} items) ===", o.get("queue_length").and_then(|x| x.as_u64()).unwrap_or(0)); + if let Some(Value::Array(items)) = o.get("queue") { + for (i, item) in items.iter().enumerate() { + let line = match item { + Value::Object(m) => { + let title = m.get("title").and_then(|x| x.as_str()).unwrap_or("?"); + let artist = m.get("artist").and_then(|x| x.as_str()).unwrap_or("?"); + let id = m.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + format!("[{i}] {artist} — {title} ({id})") + } + _ => format!("[{i}] {}", value_inline(item)), + }; + println!("{line}"); + } + } else { + println!("(no queue array in snapshot)"); + } +} + +fn sorted_kv(m: &serde_json::Map) -> Vec<(&String, &Value)> { + let mut v: Vec<_> = m.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + v +} + +fn value_inline(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "(null)".into(), + Value::Array(a) => format!("[{} elements]", a.len()), + Value::Object(m) => format!("{{{} keys}}", m.len()), + } +} + +fn parse_player_cli_at(args: &[String], pos: usize) -> Option { + let verb = args.get(pos + 1)?.as_str(); + match verb { + "next" => Some(PlayerCliCmd::Next), + "prev" => Some(PlayerCliCmd::Prev), + "play" => Some(PlayerCliCmd::Play), + "pause" => Some(PlayerCliCmd::Pause), + "seek" => { + let raw = args.get(pos + 2)?; + 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, + }) + } + _ => None, + } +} + +/// Parse transport / playback / device / mix `psysonic --player …` argv. +pub fn parse_cli_command(args: &[String]) -> Option { + let pos = args.iter().position(|a| a == "--player")?; + let verb = args.get(pos + 1)?.as_str(); + match verb { + "audio-device" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::AudioDeviceList), + "set" => { + let arg = args.get(pos + 3)?; + let name = if arg == "default" { + None + } else { + Some(arg.clone()) + }; + Some(CliCommand::AudioDeviceSet(name)) + } + _ => None, + } + } + "mix" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "append" => Some(CliCommand::Mix(MixCliMode::Append)), + "new" => Some(CliCommand::Mix(MixCliMode::New)), + _ => None, + } + } + "library" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::LibraryList), + "set" => { + let arg = args.get(pos + 3)?; + Some(CliCommand::LibrarySet(arg.clone())) + } + _ => None, + } + } + _ => parse_player_cli_at(args, pos).map(CliCommand::Player), + } +} + +pub fn write_audio_device_cli_response(engine: &crate::audio::AudioEngine) -> Result<(), String> { + let devices = crate::audio::audio_list_devices_for_engine(engine); + let default_device = crate::audio::audio_default_output_device_name(); + let selected = engine.selected_device.lock().unwrap().clone(); + let v = serde_json::json!({ + "devices": devices, + "default": default_device, + "selected": selected, + }); + let path = cli_audio_device_response_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn print_audio_devices_human(v: &Value) { + if let Some(def) = v.get("default").and_then(|x| x.as_str()) { + println!("default_output: {def}"); + } else { + println!("default_output: (unknown)"); + } + if let Some(sel) = v.get("selected").and_then(|x| x.as_str()) { + println!("selected: {sel}"); + } else { + println!("selected: (host default)"); + } + println!("devices:"); + if let Some(Value::Array(devs)) = v.get("devices") { + for d in devs { + if let Some(s) = d.as_str() { + println!(" - {s}"); + } + } + } else { + println!(" (none)"); + } +} + +/// Handle `--player` argv on the primary instance. Returns `true` if argv was a CLI action +/// (do not raise/focus the main window). +pub fn handle_cli_on_primary_instance(app: &AppHandle, argv: &[String]) -> bool { + use tauri::Manager; + match parse_cli_command(argv) { + Some(CliCommand::Player(cmd)) => { + emit_player_cli_cmd(app, cmd); + true + } + Some(CliCommand::AudioDeviceList) => { + if let Some(engine) = app.try_state::() { + let _ = write_audio_device_cli_response(&*engine); + } + true + } + Some(CliCommand::AudioDeviceSet(name)) => { + let payload = name.unwrap_or_default(); + let _ = app.emit("cli:audio-device-set", payload); + true + } + Some(CliCommand::Mix(mode)) => { + let s = match mode { + MixCliMode::Append => "append", + MixCliMode::New => "new", + }; + let _ = app.emit("cli:instant-mix", s); + true + } + Some(CliCommand::LibraryList) => { + let _ = app.emit("cli:library-list", ()); + true + } + Some(CliCommand::LibrarySet(folder)) => { + let _ = app.emit("cli:library-set", folder.clone()); + true + } + None => false, + } +} + +/// Cold start: `--player …` argv handled after a short delay so the webview can attach listeners. +pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { + use tauri::Manager; + + let argv: Vec = std::env::args().collect(); + let Some(cmd) = parse_cli_command(&argv) else { + return; + }; + let quiet = wants_quiet(&argv); + let json_out = wants_cli_json_output(&argv); + let ok_line = describe_cli_command(&cmd); + let handle = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + match cmd { + CliCommand::Player(c) => { + emit_player_cli_cmd(&handle, c); + } + CliCommand::AudioDeviceList => { + if let Some(engine) = handle.try_state::() { + let _ = write_audio_device_cli_response(&*engine); + } + let text = std::fs::read_to_string(cli_audio_device_response_path()) + .unwrap_or_else(|_| "{}".into()); + if json_out { + println!("{}", text.trim()); + } else if let Ok(v) = serde_json::from_str::(&text) { + print_audio_devices_human(&v); + } else { + println!("{}", text.trim()); + } + } + CliCommand::AudioDeviceSet(name) => { + let payload = name.unwrap_or_default(); + let _ = handle.emit("cli:audio-device-set", payload); + } + CliCommand::Mix(mode) => { + let s = match mode { + MixCliMode::Append => "append", + MixCliMode::New => "new", + }; + let _ = handle.emit("cli:instant-mix", s); + } + CliCommand::LibraryList => { + let _ = handle.emit("cli:library-list", ()); + let text = read_library_cli_response_blocking(Duration::from_secs(3)); + print_library_cli_stdout(&text, json_out); + } + CliCommand::LibrarySet(folder) => { + let _ = handle.emit("cli:library-set", folder.clone()); + } + } + if !quiet { + println!("OK: {ok_line} (applied after startup)"); + } + }); +} + +pub fn describe_cli_command(cmd: &CliCommand) -> String { + match cmd { + CliCommand::Player(c) => describe_player_cli_cmd(*c), + CliCommand::AudioDeviceList => "audio-device list".into(), + CliCommand::AudioDeviceSet(None) => "audio-device set default".into(), + CliCommand::AudioDeviceSet(Some(s)) => format!("audio-device set {s}"), + CliCommand::Mix(MixCliMode::Append) => "mix append".into(), + CliCommand::Mix(MixCliMode::New) => "mix new".into(), + CliCommand::LibraryList => "library list".into(), + CliCommand::LibrarySet(s) if s == "all" => "library set all".into(), + CliCommand::LibrarySet(s) => format!("library set {s}"), + } +} + +pub fn describe_player_cli_cmd(cmd: PlayerCliCmd) -> String { + match cmd { + PlayerCliCmd::Next => "next track".into(), + PlayerCliCmd::Prev => "previous track".into(), + PlayerCliCmd::Play => "play".into(), + PlayerCliCmd::Pause => "pause".into(), + PlayerCliCmd::Seek { delta_secs } => format!("seek {delta_secs:+} s"), + PlayerCliCmd::Volume { percent } => format!("volume {percent}%"), + } +} + +pub fn emit_player_cli_cmd(app: &AppHandle, cmd: PlayerCliCmd) { + match cmd { + PlayerCliCmd::Next => { + let _ = app.emit("media:next", ()); + } + PlayerCliCmd::Prev => { + let _ = app.emit("media:prev", ()); + } + PlayerCliCmd::Play => { + let _ = app.emit("media:play", ()); + } + PlayerCliCmd::Pause => { + let _ = app.emit("media:pause", ()); + } + PlayerCliCmd::Seek { delta_secs } => { + let _ = app.emit("media:seek-relative", delta_secs); + } + PlayerCliCmd::Volume { percent } => { + let _ = app.emit("media:set-volume", percent); + } + } +} + +/// Linux: if a primary instance owns the single-instance bus name, forward argv and +/// signal the caller process should exit successfully. Otherwise continue normal startup. +#[cfg(target_os = "linux")] +pub enum LinuxPlayerForwardResult { + Forwarded, + ContinueStartup, +} + +#[cfg(target_os = "linux")] +pub fn linux_try_forward_player_cli_secondary(args: &[String]) -> Result { + use zbus::blocking::Connection; + + let well_known = single_instance_bus_name(); + let conn = Connection::session().map_err(|e| format!("D-Bus session: {e}"))?; + + if !linux_bus_name_has_owner(&conn, well_known.as_str())? { + return Ok(LinuxPlayerForwardResult::ContinueStartup); + } + + let cwd = std::env::current_dir().unwrap_or_default(); + let cwd_s = cwd.to_str().unwrap_or("").to_string(); + let argv = args.to_vec(); + let path = single_instance_object_path(&well_known); + + match parse_cli_command(args) { + Some(CliCommand::AudioDeviceList) => { + let _ = std::fs::remove_file(cli_audio_device_response_path()); + } + Some(CliCommand::LibraryList) => { + let _ = std::fs::remove_file(cli_library_response_path()); + } + _ => {} + } + + conn.call_method( + Some(well_known.as_str()), + path.as_str(), + Some("org.SingleInstance.DBus"), + "ExecuteCallback", + &(argv, cwd_s), + ) + .map_err(|e| format!("forward to running instance: {e}"))?; + + if let Some(CliCommand::AudioDeviceList) = parse_cli_command(args) { + let resp_path = cli_audio_device_response_path(); + let text = std::fs::read_to_string(&resp_path).unwrap_or_else(|_| "{}".into()); + if wants_cli_json_output(args) { + println!("{}", text.trim()); + } else if let Ok(v) = serde_json::from_str::(&text) { + print_audio_devices_human(&v); + } else { + println!("{}", text.trim()); + } + if !wants_quiet(args) { + println!("OK: audio-device list"); + } + } else if let Some(CliCommand::LibraryList) = parse_cli_command(args) { + let json_out = wants_cli_json_output(args); + let text = read_library_cli_response_blocking(Duration::from_secs(3)); + print_library_cli_stdout(&text, json_out); + if !wants_quiet(args) { + println!("OK: library list"); + } + } else if !wants_quiet(args) { + if let Some(cmd) = parse_cli_command(args) { + println!("OK: {}", describe_cli_command(&cmd)); + } else { + println!("OK"); + } + } + + Ok(LinuxPlayerForwardResult::Forwarded) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a7c882e3..65e54efc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod audio; +pub mod cli; mod discord; #[cfg(target_os = "windows")] mod taskbar_win; @@ -45,6 +46,18 @@ fn exit_app(app_handle: tauri::AppHandle) { app_handle.exit(0); } +/// Writes `psysonic-cli-snapshot.json` for `psysonic --info` (debounced from the frontend). +#[tauri::command] +fn cli_publish_player_snapshot(snapshot: serde_json::Value) -> Result<(), String> { + crate::cli::write_cli_snapshot(&snapshot) +} + +/// Writes `psysonic-cli-library.json` for `psysonic --player library list`. +#[tauri::command] +fn cli_publish_library_list(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_library_cli_response(&payload) +} + /// Toggle native window decorations at runtime (Linux custom title bar opt-out). #[tauri::command] fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { @@ -2199,6 +2212,23 @@ fn is_tiling_wm_cmd() -> bool { } pub fn run() { + let argv: Vec = std::env::args().collect(); + + // Linux: second `psysonic --player …` forwards over D-Bus before heavy startup. + #[cfg(target_os = "linux")] + { + 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) => { + eprintln!("NOT OK: {msg}"); + std::process::exit(1); + } + } + } + } + let (audio_engine, _audio_thread) = audio::create_engine(); tauri::Builder::default() @@ -2214,11 +2244,13 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - let window = app.get_webview_window("main").expect("no main window"); - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + if !crate::cli::handle_cli_on_primary_instance(app, &argv) { + let window = app.get_webview_window("main").expect("no main window"); + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } })) .setup(|app| { @@ -2350,6 +2382,9 @@ pub fn run() { audio::start_device_watcher(&engine, app.handle().clone()); } + // Cold start with `--player …`: defer emit so the webview can register listeners. + crate::cli::spawn_deferred_cli_argv_handler(app.handle()); + Ok(()) }) .on_window_event(|window, event| { @@ -2378,6 +2413,8 @@ pub fn run() { greet, calculate_sync_payload, exit_app, + cli_publish_player_snapshot, + cli_publish_library_list, set_window_decorations, no_compositing_mode, is_tiling_wm_cmd, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4c99982e..0e2cda4c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -13,5 +13,24 @@ fn main() { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); } } + + let args: Vec = std::env::args().collect(); + if psysonic_lib::cli::wants_version(&args) { + psysonic_lib::cli::print_version(); + return; + } + if psysonic_lib::cli::wants_help(&args) { + psysonic_lib::cli::print_help( + args.first().map(|s| s.as_str()).unwrap_or("psysonic"), + ); + return; + } + if let Some(code) = psysonic_lib::cli::try_completions_dispatch(&args) { + std::process::exit(code); + } + if psysonic_lib::cli::wants_info(&args) { + psysonic_lib::cli::run_info_and_exit(&args); + } + psysonic_lib::run(); } diff --git a/src/App.tsx b/src/App.tsx index 5ff3f4cf..35efc81d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,10 +58,11 @@ import { IS_LINUX } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; -import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic'; +import { getMusicFolders, getSimilarSongs, probeEntityRatingSupport } from './api/subsonic'; import { useOfflineStore } from './store/offlineStore'; import { initHotCachePrefetch } from './hotCachePrefetch'; -import { usePlayerStore, initAudioListeners } from './store/playerStore'; +import i18n from './i18n'; +import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore'; import { useThemeStore } from './store/themeStore'; import { useThemeScheduler } from './hooks/useThemeScheduler'; import { useFontStore } from './store/fontStore'; @@ -453,6 +454,96 @@ function TauriEventBridge() { return () => { unlisten?.(); }; }, []); + // CLI: `--player audio-device set …` (forwarded on Linux via single-instance). + useEffect(() => { + let unlisten: (() => void) | undefined; + listen('cli:audio-device-set', async e => { + const raw = typeof e.payload === 'string' ? e.payload : ''; + const deviceName = raw.length > 0 ? raw : null; + try { + await invoke('audio_set_device', { deviceName }); + useAuthStore.getState().setAudioOutputDevice(deviceName); + } catch { + /* device open failed — do not persist (same as Settings) */ + } + }).then(u => { unlisten = u; }); + return () => { unlisten?.(); }; + }, []); + + // CLI: `--player mix append|new` from the currently playing track. + useEffect(() => { + let unlisten: (() => void) | undefined; + listen('cli:instant-mix', async e => { + const mode = e.payload === 'append' ? 'append' : 'new'; + const state = usePlayerStore.getState(); + const song = state.currentTrack; + if (!song) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + const serverId = useAuthStore.getState().activeServerId; + try { + const similar = await getSimilarSongs(song.id, 50); + if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false); + const base = similar.filter(s => s.id !== song.id).map(s => songToTrack(s)); + if (mode === 'append') { + const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const }))); + if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd); + } else { + // New queue from seed: collapse to [song] first, then radio tail (not append onto old queue). + usePlayerStore.getState().reseedQueueForInstantMix(song); + const shuffled = shuffleArray( + base.map(t => ({ ...t, radioAdded: true as const })), + ); + if (shuffled.length > 0) { + const aid = song.artistId?.trim() || undefined; + usePlayerStore.getState().enqueueRadio(shuffled, aid); + } + } + } catch (err) { + console.error('CLI instant mix failed', err); + if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true); + showToast(i18n.t('contextMenu.instantMixFailed'), 5000, 'error'); + } + }).then(u => { unlisten = u; }); + return () => { unlisten?.(); }; + }, []); + + // CLI: `--player library list` (Rust polls the JSON file) / `library set`. + useEffect(() => { + let u1: (() => void) | undefined; + let u2: (() => void) | undefined; + listen('cli:library-list', async () => { + try { + const folders = await getMusicFolders(); + const auth = useAuthStore.getState(); + const sid = auth.activeServerId; + const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all'; + await invoke('cli_publish_library_list', { + payload: { + folders: folders.map(f => ({ id: f.id, name: f.name })), + selected, + active_server_id: sid, + }, + }); + } catch (e) { + console.error('CLI library list failed', e); + await invoke('cli_publish_library_list', { + payload: { folders: [], selected: 'all', active_server_id: null }, + }).catch(() => {}); + } + }).then(u => { u1 = u; }); + listen('cli:library-set', e => { + const raw = typeof e.payload === 'string' ? e.payload : ''; + if (raw === 'all') useAuthStore.getState().setMusicLibraryFilter('all'); + else if (raw.length > 0) useAuthStore.getState().setMusicLibraryFilter(raw); + }).then(u => { u2 = u; }); + return () => { + u1?.(); + u2?.(); + }; + }, []); + // Sync tray-icon visibility with the user's stored setting. // Runs once on mount (initial sync) and again whenever the setting changes. const showTrayIcon = useAuthStore(s => s.showTrayIcon); @@ -524,6 +615,8 @@ function TauriEventBridge() { const setup = async () => { const handlers: Array<[string, () => void]> = [ ['media:play-pause', () => togglePlay()], + ['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }], + ['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }], ['media:next', () => next()], ['media:prev', () => previous()], ['tray:play-pause', () => togglePlay()], @@ -559,6 +652,15 @@ function TauriEventBridge() { if (cancelled) { u(); return; } unlisten.push(u); } + { + const u = await listen('media:set-volume', e => { + const p = e.payload; + if (typeof p !== 'number' || Number.isNaN(p)) return; + usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100))); + }); + if (cancelled) { u(); return; } + unlisten.push(u); + } // window:close-requested is emitted by Rust (prevent_close + emit). // JS decides: minimize to tray or exit, based on user setting. @@ -577,6 +679,48 @@ function TauriEventBridge() { return () => { cancelled = true; unlisten.forEach(u => u()); }; }, [togglePlay, next, previous]); + // `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically). + useEffect(() => { + let tid: ReturnType | undefined; + const publish = () => { + const s = usePlayerStore.getState(); + const auth = useAuthStore.getState(); + const sid = auth.activeServerId; + const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all'; + const snapshot = { + current_track: s.currentTrack, + current_radio: s.currentRadio, + queue: s.queue, + queue_index: s.queueIndex, + queue_length: s.queue.length, + is_playing: s.isPlaying, + current_time: s.currentTime, + volume: s.volume, + music_library: { + active_server_id: sid, + selected, + folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })), + }, + }; + invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {}); + }; + publish(); + const schedule = () => { + if (tid !== undefined) clearTimeout(tid); + tid = setTimeout(() => { + tid = undefined; + publish(); + }, 200); + }; + const unsubP = usePlayerStore.subscribe(schedule); + const unsubA = useAuthStore.subscribe(schedule); + return () => { + unsubP(); + unsubA(); + if (tid !== undefined) clearTimeout(tid); + }; + }, []); + return null; } diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 66dc3f58..96db0f58 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -1,6 +1,11 @@ +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { Check, ChevronDown } from 'lucide-react'; import { ConnectionStatus } from '../hooks/useConnectionStatus'; +import { useAuthStore, type ServerProfile } from '../store/authStore'; +import { switchActiveServer } from '../utils/switchActiveServer'; +import { showToast } from '../utils/toast'; interface Props { status: ConnectionStatus; @@ -11,28 +16,141 @@ interface Props { export default function ConnectionIndicator({ status, isLan, serverName }: Props) { const { t } = useTranslation(); const navigate = useNavigate(); + const servers = useAuthStore(s => s.servers); + const activeServerId = useAuthStore(s => s.activeServerId); + const [menuOpen, setMenuOpen] = useState(false); + const [switchingId, setSwitchingId] = useState(null); + const hostRef = useRef(null); + + const multi = servers.length > 1; + + useEffect(() => { + if (!menuOpen) return; + const onDown = (e: MouseEvent) => { + if (hostRef.current?.contains(e.target as Node)) return; + setMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [menuOpen]); + + const goServerSettings = () => { + setMenuOpen(false); + navigate('/settings', { state: { tab: 'server' } }); + }; + + const onTriggerClick = () => { + if (!multi) { + goServerSettings(); + return; + } + setMenuOpen(o => !o); + }; + + const onPickServer = async (srv: ServerProfile) => { + if (srv.id === activeServerId) { + setMenuOpen(false); + return; + } + setSwitchingId(srv.id); + const ok = await switchActiveServer(srv); + setSwitchingId(null); + setMenuOpen(false); + if (!ok) { + showToast(t('connection.switchFailed'), 5000, 'error'); + return; + } + navigate('/'); + }; const label = isLan ? 'LAN' : t('connection.extern'); - const tooltip = - status === 'connected' + const tooltip = multi + ? t('connection.switchServerHint') + : status === 'connected' ? t('connection.connectedTo', { server: serverName }) : status === 'disconnected' - ? t('connection.disconnectedFrom', { server: serverName }) - : t('connection.checking'); + ? t('connection.disconnectedFrom', { server: serverName }) + : t('connection.checking'); return ( -
navigate('/settings', { state: { tab: 'server' } })} - data-tooltip={tooltip} - data-tooltip-pos="bottom" - > -
-
- {label} - {serverName} +
+
+
+
+ {label} + + {serverName} + {multi && ( + + )} + +
+ {multi && menuOpen && ( +
+
+ {t('connection.switchServerTitle')} +
+ {servers.map(srv => { + const active = srv.id === activeServerId; + const busy = switchingId !== null; + const labelText = (srv.name || srv.url).trim() || srv.url; + return ( + + ); + })} +
+ +
+ )}
); } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 9791fd89..a4877900 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1344,12 +1344,7 @@ export default function ContextMenu() { }; const startInstantMix = async (song: Track) => { - const state = usePlayerStore.getState(); - if (state.currentTrack?.id === song.id) { - if (!state.isPlaying) state.resume(); - } else { - playTrack(song, [song]); - } + usePlayerStore.getState().reseedQueueForInstantMix(song); const serverId = useAuthStore.getState().activeServerId; try { const similar = await getSimilarSongs(song.id, 50); diff --git a/src/locales/de.ts b/src/locales/de.ts index d7b04ab2..ee8f0f5c 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -101,6 +101,7 @@ export const deTranslation = { startRadio: 'Radio starten', instantMix: 'Instant Mix', instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.', + cliMixNeedsTrack: 'Es läuft nichts — starte zuerst die Wiedergabe und führe den Mix-Befehl erneut aus.', lfmLove: 'Auf Last.fm liken', lfmUnlove: 'Last.fm-Like entfernen', favorite: 'Favorisieren', @@ -355,6 +356,10 @@ export const deTranslation = { offlineFilterArtists: 'Diskografien', retry: 'Erneut versuchen', serverSettings: 'Server-Einstellungen', + switchServerTitle: 'Server wechseln', + switchServerHint: 'Klicken, um einen anderen gespeicherten Server zu wählen.', + manageServers: 'Server verwalten…', + switchFailed: 'Wechsel fehlgeschlagen — Server nicht erreichbar.', lastfmConnected: 'Last.fm verbunden als @{{user}}', lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden', }, diff --git a/src/locales/en.ts b/src/locales/en.ts index d4de77cd..7a9a8b85 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -102,6 +102,7 @@ export const enTranslation = { startRadio: 'Start Radio', instantMix: 'Instant Mix', instantMixFailed: 'Could not build Instant Mix — server or plugin error.', + cliMixNeedsTrack: 'Nothing is playing — start playback first, then run the mix command again.', lfmLove: 'Love on Last.fm', lfmUnlove: 'Unlove on Last.fm', favorite: 'Favorite', @@ -356,6 +357,10 @@ export const enTranslation = { offlineFilterArtists: 'Discographies', retry: 'Retry', serverSettings: 'Server Settings', + switchServerTitle: 'Switch server', + switchServerHint: 'Click to choose another saved server.', + manageServers: 'Manage servers…', + switchFailed: 'Could not switch — server unreachable.', lastfmConnected: 'Last.fm connected as @{{user}}', lastfmSessionInvalid: 'Session invalid — click to re-connect', }, diff --git a/src/locales/es.ts b/src/locales/es.ts index 743c8132..74e70e2a 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -102,6 +102,7 @@ export const esTranslation = { startRadio: 'Iniciar Radio', instantMix: 'Mezcla Instantánea', instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.', + cliMixNeedsTrack: 'No hay nada en reproducción — inicia la reproducción y vuelve a ejecutar el comando de mezcla.', lfmLove: 'Favorito en Last.fm', lfmUnlove: 'Quitar favorito de Last.fm', favorite: 'Favorito', @@ -357,6 +358,10 @@ export const esTranslation = { offlineFilterArtists: 'Discografías', retry: 'Reintentar', serverSettings: 'Configuración del servidor', + switchServerTitle: 'Cambiar servidor', + switchServerHint: 'Clic para elegir otro servidor guardado.', + manageServers: 'Gestionar servidores…', + switchFailed: 'No se pudo cambiar — servidor inalcanzable.', lastfmConnected: 'Last.fm conectado como @{{user}}', lastfmSessionInvalid: 'Sesión inválida — click para reconectar', }, diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b5fa9858..f4b7e8a1 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -101,6 +101,7 @@ export const frTranslation = { startRadio: 'Démarrer la radio', instantMix: 'Mix instantané', instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.', + cliMixNeedsTrack: 'Aucune lecture en cours — lancez d’abord la lecture, puis relancez la commande de mix.', lfmLove: 'Aimer sur Last.fm', lfmUnlove: 'Ne plus aimer sur Last.fm', favorite: 'Favori', @@ -355,6 +356,10 @@ export const frTranslation = { offlineFilterArtists: 'Discographies', retry: 'Réessayer', serverSettings: 'Paramètres serveur', + switchServerTitle: 'Changer de serveur', + switchServerHint: 'Cliquez pour choisir un autre serveur enregistré.', + manageServers: 'Gérer les serveurs…', + switchFailed: 'Impossible de changer — serveur injoignable.', lastfmConnected: 'Last.fm connecté en tant que @{{user}}', lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter', }, diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 0a3b13e3..7f880a9a 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -101,6 +101,7 @@ export const nbTranslation = { startRadio: 'Start radio', instantMix: 'Instant Mix', instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.', + cliMixNeedsTrack: 'Ingenting spilles — start avspilling først, og kjør mix-kommandoen på nytt.', lfmLove: 'Lik på Last.fm', lfmUnlove: 'Fjern fra likte på Last.fm', favorite: 'Favoritt', @@ -355,6 +356,10 @@ export const nbTranslation = { offlineFilterArtists: 'Diskografier', retry: 'Prøv igjen', serverSettings: 'Serverinnstillinger', + switchServerTitle: 'Bytt server', + switchServerHint: 'Klikk for å velge en annen lagret server.', + manageServers: 'Administrer servere…', + switchFailed: 'Klarte ikke å bytte — serveren er utilgjengelig.', lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}', lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt', }, diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 2af79694..e39f933b 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -101,6 +101,7 @@ export const nlTranslation = { startRadio: 'Radio starten', instantMix: 'Instant Mix', instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.', + cliMixNeedsTrack: 'Er speelt niets — start eerst afspelen en voer het mix-commando opnieuw uit.', lfmLove: 'Liken op Last.fm', lfmUnlove: 'Niet meer liken op Last.fm', favorite: 'Favoriet', @@ -354,6 +355,10 @@ export const nlTranslation = { offlineFilterArtists: 'Discografieën', retry: 'Opnieuw proberen', serverSettings: 'Serverinstellingen', + switchServerTitle: 'Server wisselen', + switchServerHint: 'Klik om een andere opgeslagen server te kiezen.', + manageServers: 'Servers beheren…', + switchFailed: 'Wisselen mislukt — server niet bereikbaar.', lastfmConnected: 'Last.fm verbonden als @{{user}}', lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden', }, diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 6619c3df..49157fd2 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -102,6 +102,7 @@ export const ruTranslation = { startRadio: 'Радио по похожим', instantMix: 'Instant Mix', instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.', + cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.', lfmLove: 'Любимое на Last.fm', lfmUnlove: 'Убрать с Last.fm', favorite: 'В избранное', @@ -369,6 +370,10 @@ export const ruTranslation = { offlineFilterArtists: 'Дискографии', retry: 'Повторить', serverSettings: 'Настройки сервера', + switchServerTitle: 'Сменить сервер', + switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.', + manageServers: 'Управление серверами…', + switchFailed: 'Не удалось переключиться — сервер недоступен.', lastfmConnected: 'Last.fm: @{{user}}', lastfmSessionInvalid: 'Сессия недействительна — подключите снова', }, diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 9044107b..49cb2ea1 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -101,6 +101,7 @@ export const zhTranslation = { startRadio: '开始电台', instantMix: '即时混音', instantMixFailed: '无法生成即时混音 — 服务器或插件出错。', + cliMixNeedsTrack: '当前没有正在播放的内容 — 请先开始播放,然后再执行混音命令。', lfmLove: '在 Last.fm 上标记喜欢', lfmUnlove: '取消 Last.fm 喜欢标记', favorite: '收藏', @@ -350,6 +351,10 @@ export const zhTranslation = { offlineAlbumCount_plural: '{{n}} 张专辑', retry: '重试', serverSettings: '服务器设置', + switchServerTitle: '切换服务器', + switchServerHint: '点击选择其他已保存的服务器。', + manageServers: '管理服务器…', + switchFailed: '无法切换 — 无法连接服务器。', lastfmConnected: 'Last.fm 已连接为 @{{user}}', lastfmSessionInvalid: '会话无效 — 点击重新连接', }, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 4687d993..e135e10b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -33,6 +33,7 @@ import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; import { ALL_NAV_ITEMS } from '../components/Sidebar'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; +import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { Trans, useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; @@ -572,23 +573,11 @@ export default function Settings() { const switchToServer = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); - try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (ping.ok) { - const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, - }; - auth.setSubsonicServerIdentity(server.id, identity); - scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); - auth.setActiveServer(server.id); - auth.setLoggedIn(true); - navigate('/'); - } else { - setConnStatus(s => ({ ...s, [server.id]: 'error' })); - } - } catch { + const ok = await switchActiveServer(server); + if (ok) { + setConnStatus(s => ({ ...s, [server.id]: 'ok' })); + navigate('/'); + } else { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index eb0c8bcc..470c21cc 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -64,7 +64,7 @@ export function songToTrack(song: SubsonicSong): Track { }; } -function shuffleArray(items: T[]): T[] { +export function shuffleArray(items: T[]): T[] { const arr = [...items]; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -131,6 +131,8 @@ interface PlayerState { playRadio: (station: InternetRadioStation) => void; playTrack: (track: Track, queue?: Track[], manual?: boolean) => void; + /** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */ + reseedQueueForInstantMix: (track: Track) => void; pause: () => void; resume: () => void; stop: () => void; @@ -942,6 +944,22 @@ export const usePlayerStore = create()( touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); }, + reseedQueueForInstantMix: (track) => { + const s = get(); + if (s.currentTrack?.id !== track.id) { + get().playTrack(track, [track]); + return; + } + const wasPlaying = s.isPlaying; + set({ + queue: [track], + queueIndex: 0, + currentTrack: track, + }); + syncQueueToServer([track], track, s.currentTime); + if (!wasPlaying) get().resume(); + }, + // ── pause / resume / togglePlay ────────────────────────────────────────── pause: () => { if (get().currentRadio) { diff --git a/src/styles/components.css b/src/styles/components.css index 72bebb9d..557aada1 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -4090,6 +4090,24 @@ html.no-compositing .fs-seekbar-played { } /* ─ Connection Indicator ─ */ +.connection-indicator-host { + position: relative; + flex-shrink: 0; +} + +.connection-indicator-dropdown-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + max-width: min(320px, 85vw); +} + +.connection-indicator-chevron--open { + transform: rotate(180deg); + transition: transform var(--transition-fast, 0.15s ease); +} + .connection-indicator { display: flex; align-items: center; diff --git a/src/utils/switchActiveServer.ts b/src/utils/switchActiveServer.ts new file mode 100644 index 00000000..a3215985 --- /dev/null +++ b/src/utils/switchActiveServer.ts @@ -0,0 +1,30 @@ +import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; +import type { ServerProfile } from '../store/authStore'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; + +/** + * Ping, update server identity / Instant Mix probe, set active server, clear local playback + * and pull the new server's saved play queue. + */ +export async function switchActiveServer(server: ServerProfile): Promise { + try { + const ping = await pingWithCredentials(server.url, server.username, server.password); + if (!ping.ok) return false; + const identity = { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + }; + const auth = useAuthStore.getState(); + auth.setSubsonicServerIdentity(server.id, identity); + scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); + auth.setActiveServer(server.id); + auth.setLoggedIn(true); + usePlayerStore.getState().clearQueue(); + await usePlayerStore.getState().initializeFromServerQueue(); + return true; + } catch { + return false; + } +} From 6533991e7de1579555dbdeb457fff1888314d4ef Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Wed, 15 Apr 2026 01:43:58 +0300 Subject: [PATCH 2/2] feat(cli): expand remote player, opaque play id, tray without libindicator Add CLI commands for stop, shuffle, repeat, mute, star, rating, reload, server list/switch, and Subsonic search; publish extra snapshot fields and server/search JSON for tooling. Single `play ` resolves track, album, or artist; artists play a shuffled full discography. Guard Linux tray creation with catch_unwind when Ayatana/AppIndicator .so is missing. Harden bash completion against zsh sourcing (compopt). Narrow AudioEngine field visibility to silence private_interfaces warnings. --- completions/_psysonic | 45 +++- completions/psysonic.bash | 64 ++++- src-tauri/src/audio.rs | 6 +- src-tauri/src/cli.rs | 463 +++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 53 +++- src/App.tsx | 193 ++++++++++++- src/utils/playArtistShuffled.ts | 25 ++ src/utils/playByOpaqueId.ts | 35 +++ 8 files changed, 864 insertions(+), 20 deletions(-) create mode 100644 src/utils/playArtistShuffled.ts create mode 100644 src/utils/playByOpaqueId.ts diff --git a/completions/_psysonic b/completions/_psysonic index ff680d96..e8391b37 100644 --- a/completions/_psysonic +++ b/completions/_psysonic @@ -39,6 +39,25 @@ _psysonic_library_folder_ids() { (( $#ids )) && compadd -a ids } +_psysonic_snapshot_json() { + local f + if [[ -n $XDG_RUNTIME_DIR ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json" + [[ -r $f ]] && { print -r -- "$f"; return } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json" + [[ -r $f ]] && print -r -- "$f" +} + +_psysonic_server_ids() { + command -v jq &>/dev/null || return + local jf + jf="$(_psysonic_snapshot_json)" || return + local -a ids + ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} ) + (( $#ids )) && compadd -a ids +} + _psysonic_globals() { compadd -J options -X 'option' -- \ --help --version --info --json --quiet --player completions @@ -68,7 +87,8 @@ fi integer n=${#sub[@]} if (( n == 0 )); then compadd -J verbs -X 'player command' -- \ - next prev play pause seek volume audio-device library mix + next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \ + audio-device library server search mix return fi @@ -92,10 +112,33 @@ case ${sub[1]} in mix) (( n == 1 )) && compadd append new ;; + server) + if (( n == 1 )); then + compadd list set + elif [[ ${sub[2]} == set ]] && (( n == 2 )); then + _psysonic_server_ids + fi + ;; + search) + if (( n == 1 )); then + compadd track album artist + elif (( n >= 2 )); then + _message -e descriptions 'search query' + fi + ;; + repeat) + (( n == 1 )) && compadd off all one + ;; + rating) + (( n == 1 )) && compadd 0 1 2 3 4 5 + ;; seek) (( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)' ;; volume) (( n == 1 )) && _message -e descriptions 'percent 0–100' ;; + play) + (( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)' + ;; esac diff --git a/completions/psysonic.bash b/completions/psysonic.bash index 44f4e507..67fc0296 100644 --- a/completions/psysonic.bash +++ b/completions/psysonic.bash @@ -3,6 +3,14 @@ # Optional: jq + prior `psysonic --player audio-device list` for device name completion. # # Uses no `mapfile` so bash 3.2 (macOS default) works. +# +# compopt is bash-only (programmable completion). Guard so sourcing this file +# under zsh or plain sh does not print "command not found: compopt". + +_psysonic_compopt() { + command -v compopt &>/dev/null || return 0 + compopt "$@" 2>/dev/null || return 0 +} _psysonic_compreply_from_compgen() { # $1 = compgen -W word list, $2 = current word @@ -33,6 +41,16 @@ _psysonic_library_json() { [[ -r $f ]] && printf '%s' "$f" } +_psysonic_snapshot_json() { + local f + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json" + [[ -r $f ]] && { printf '%s' "$f"; return; } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json" + [[ -r $f ]] && printf '%s' "$f" +} + _psysonic_complete() { local cur cur="${COMP_WORDS[COMP_CWORD]}" @@ -58,7 +76,7 @@ _psysonic_complete() { local n=${#sub[@]} if (( n == 0 )); then - _psysonic_compreply_from_compgen 'next prev play pause seek volume audio-device library mix' "$cur" + _psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur" return fi @@ -78,7 +96,7 @@ _psysonic_complete() { while IFS= read -r line; do [[ -n $line ]] && COMPREPLY+=("$line") done < <(compgen -W 'default' -- "$cur") - ((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null + ((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames fi ;; library) @@ -96,14 +114,52 @@ _psysonic_complete() { while IFS= read -r line; do [[ -n $line ]] && COMPREPLY+=("$line") done < <(compgen -W 'all' -- "$cur") - ((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null + ((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames fi ;; mix) (( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur" ;; + server) + if (( n == 1 )); then + _psysonic_compreply_from_compgen 'list set' "$cur" + elif [[ ${sub[1]} == set ]] && (( n == 2 )); then + COMPREPLY=() + local jf sid + jf="$(_psysonic_snapshot_json)" + if [[ -n $jf ]] && command -v jq &>/dev/null; then + while IFS= read -r sid; do + [[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid") + done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null) + fi + ((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames + fi + ;; + search) + if (( n == 1 )); then + _psysonic_compreply_from_compgen 'track album artist' "$cur" + elif (( n >= 2 )); then + _psysonic_compopt -o default + COMPREPLY=() + fi + ;; + repeat) + (( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur" + ;; + rating) + (( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur" + ;; seek|volume) - (( n == 1 )) && compopt -o default && COMPREPLY=() + if (( n == 1 )); then + _psysonic_compopt -o default + COMPREPLY=() + fi + ;; + play) + if (( n == 1 )); then + _psysonic_compopt -o default + COMPREPLY=() + fi ;; esac } diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 794e86fe..9c9d0877 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1473,7 +1473,7 @@ pub struct AudioEngine { pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, pub eq_pre_gain: Arc, - pub preloaded: Arc>>, + pub(crate) preloaded: Arc>>, pub crossfade_enabled: Arc, pub crossfade_secs: Arc, pub fading_out_sink: Arc>>, @@ -1482,7 +1482,7 @@ pub struct AudioEngine { pub gapless_enabled: Arc, /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. - pub chained_info: Arc>>, + pub(crate) chained_info: Arc>>, /// Atomic sample counter — incremented by CountingSource in the audio thread. /// Progress task reads this for drift-free position tracking. pub samples_played: Arc, @@ -1495,7 +1495,7 @@ pub struct AudioEngine { pub gapless_switch_at: Arc, /// Active radio session state. None for regular (non-radio) tracks. /// Dropping the value aborts the HTTP download task via RadioLiveState::Drop. - pub radio_state: Mutex>, + pub(crate) radio_state: Mutex>, } pub struct AudioCurrent { diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 7b8d9543..84d226db 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -12,13 +12,37 @@ use serde_json::Value; use tauri::{AppHandle, Emitter, Runtime}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepeatCliMode { + Off, + All, + One, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SearchCliScope { + Track, + Album, + Artist, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub enum PlayerCliCmd { Next, Prev, Play, + 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)] @@ -37,6 +61,12 @@ pub enum CliCommand { /// `"all"` or a music-folder id from `library list`. LibrarySet(String), Mix(MixCliMode), + ServerList, + ServerSet(String), + Search { + scope: SearchCliScope, + query: String, + }, } pub fn wants_version(args: &[String]) -> bool { @@ -63,7 +93,7 @@ pub fn wants_quiet(args: &[String]) -> bool { .any(|a| a == "--quiet" || a == "-q") } -/// Machine-readable output for `--json` with `audio-device list` or `library list`. +/// Machine-readable output for `--json` with list/search commands (`audio-device`, `library`, `server`, `search`). pub fn wants_cli_json_output(args: &[String]) -> bool { args.iter().skip(1).any(|a| a == "--json") } @@ -100,6 +130,24 @@ pub fn cli_library_response_path() -> PathBuf { std::env::temp_dir().join("psysonic-cli-library.json") } +pub fn cli_server_list_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-servers.json"); + } + } + std::env::temp_dir().join("psysonic-cli-servers.json") +} + +pub fn cli_search_response_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-search.json"); + } + } + std::env::temp_dir().join("psysonic-cli-search.json") +} + /// `psysonic completions …` — returns exit code when this argv should not start the GUI. pub fn try_completions_dispatch(args: &[String]) -> Option { if args.get(1).map(|s| s.as_str()) != Some("completions") { @@ -186,18 +234,32 @@ pub fn print_help(program: &str) { eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); 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` or `library list`: JSON on stdout."); + 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"); + eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); + 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.\n"); + 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!(" Audio output"); eprintln!(" {program} [--json] --player audio-device list"); eprintln!(" {program} --player audio-device set \n"); eprintln!(" Music library (Subsonic music folders for the active server)"); eprintln!(" {program} [--json] --player library list"); eprintln!(" {program} --player library set all | \n"); + eprintln!(" Servers (saved profiles — same as the in-app server switcher)"); + eprintln!(" {program} [--json] --player server list"); + eprintln!(" {program} --player server set \n"); + eprintln!(" Search (active server; respects library folder filter)"); + eprintln!(" {program} [--json] --player search track "); + eprintln!(" {program} [--json] --player search album "); + eprintln!(" {program} [--json] --player search artist \n"); eprintln!(" Instant mix (from the track that is currently loaded)"); eprintln!(" {program} --player mix append"); eprintln!(" {program} --player mix new\n"); @@ -275,6 +337,186 @@ pub fn write_library_cli_response(payload: &Value) -> Result<(), String> { Ok(()) } +pub fn write_server_list_cli_response(payload: &Value) -> Result<(), String> { + let path = cli_server_list_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(payload).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn write_search_cli_response(payload: &Value) -> Result<(), String> { + let path = cli_search_response_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(payload).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +/// Wait for `psysonic-cli-servers.json` after `cli:server-list`. +fn read_server_list_cli_response_blocking(max_wait: Duration) -> String { + let path = cli_server_list_path(); + let deadline = Instant::now() + max_wait; + loop { + if let Ok(text) = std::fs::read_to_string(&path) { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + if v.get("servers").and_then(|x| x.as_array()).is_some() { + return text; + } + } + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(40)); + } + std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".into()) +} + +pub fn print_server_list_cli_stdout(text: &str, json_out: bool) { + if json_out { + println!("{}", text.trim()); + return; + } + if let Ok(v) = serde_json::from_str::(text) { + print_server_list_human(&v); + } else { + println!("{}", text.trim()); + } +} + +fn print_server_list_human(v: &Value) { + if let Some(sid) = v.get("active_server_id").and_then(|x| x.as_str()) { + println!("active_server_id: {sid}"); + } else { + println!("active_server_id: (none)"); + } + println!("servers:"); + if let Some(Value::Array(rows)) = v.get("servers") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } else { + println!(" (invalid JSON: missing servers array)"); + } +} + +/// Wait for `psysonic-cli-search.json` after `cli:search`. +fn read_search_cli_response_blocking(max_wait: Duration) -> String { + let path = cli_search_response_path(); + let deadline = Instant::now() + max_wait; + loop { + if let Ok(text) = std::fs::read_to_string(&path) { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + let ready = v.get("ready").and_then(|x| x.as_bool()) == Some(true); + let has_err = v.get("error").and_then(|x| x.as_str()).is_some_and(|s| !s.is_empty()); + if ready || has_err { + return text; + } + } + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(40)); + } + std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".into()) +} + +pub fn print_search_cli_stdout(text: &str, json_out: bool) { + if json_out { + println!("{}", text.trim()); + return; + } + if let Ok(v) = serde_json::from_str::(text) { + print_search_human(&v); + } else { + println!("{}", text.trim()); + } +} + +fn print_search_human(v: &Value) { + if let Some(err) = v.get("error").and_then(|x| x.as_str()) { + if !err.is_empty() { + println!("error: {err}"); + return; + } + } + let scope = v.get("scope").and_then(|x| x.as_str()).unwrap_or("?"); + let query = v.get("query").and_then(|x| x.as_str()).unwrap_or(""); + println!("scope: {scope}"); + println!("query: {query}"); + match scope { + "track" => { + println!("songs:"); + if let Some(Value::Array(rows)) = v.get("songs") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let title = row.get("title").and_then(|x| x.as_str()).unwrap_or("?"); + let artist = row.get("artist").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{artist} — {title}"); + } + } else { + println!(" (missing songs array)"); + } + } + "album" => { + println!("albums:"); + if let Some(Value::Array(rows)) = v.get("albums") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + let artist = row.get("artist").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{artist} — {name}"); + } + } else { + println!(" (missing albums array)"); + } + } + "artist" => { + println!("artists:"); + if let Some(Value::Array(rows)) = v.get("artists") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } else { + println!(" (missing artists array)"); + } + } + _ => println!("(unknown scope)"), + } +} + fn tauri_identifier() -> &'static str { static ID: OnceLock = OnceLock::new(); ID.get_or_init(|| { @@ -439,12 +681,28 @@ fn print_info_human(v: &Value) { "volume", "queue_index", "queue_length", + "repeat_mode", + "current_track_user_rating", + "current_track_starred", ] { if let Some(val) = o.get(key) { println!(" {key}: {}", value_inline(val)); } } + println!("=== servers (saved) ==="); + match o.get("servers").and_then(|x| x.as_array()) { + None => println!("(none)"), + Some(rows) if rows.is_empty() => println!("(none)"), + Some(rows) => { + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } + } + println!("=== queue ({} items) ===", o.get("queue_length").and_then(|x| x.as_u64()).unwrap_or(0)); if let Some(Value::Array(items)) = o.get("queue") { for (i, item) in items.iter().enumerate() { @@ -481,13 +739,50 @@ fn value_inline(v: &Value) -> String { } } +fn parse_repeat_mode(arg: &str) -> Option { + match arg { + "off" => Some(RepeatCliMode::Off), + "all" => Some(RepeatCliMode::All), + "one" => Some(RepeatCliMode::One), + _ => None, + } +} + fn parse_player_cli_at(args: &[String], pos: usize) -> Option { let verb = args.get(pos + 1)?.as_str(); match verb { "next" => Some(PlayerCliCmd::Next), "prev" => Some(PlayerCliCmd::Prev), - "play" => Some(PlayerCliCmd::Play), + "play" => match args.get(pos + 2).map(|s| s.as_str()) { + None => Some(PlayerCliCmd::Play), + Some(flag) if flag.starts_with('-') => None, + Some(extra) => { + if extra.is_empty() { + return None; + } + Some(PlayerCliCmd::PlayOpaqueId(extra.to_string())) + } + }, "pause" => Some(PlayerCliCmd::Pause), + "stop" => Some(PlayerCliCmd::Stop), + "shuffle" => Some(PlayerCliCmd::ShuffleQueue), + "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()?; + if n > 5 { + return None; + } + Some(PlayerCliCmd::Rating { stars: n }) + } + "reload" => Some(PlayerCliCmd::ReloadPlayer), "seek" => { let raw = args.get(pos + 2)?; let delta_secs: i32 = raw.parse().ok()?; @@ -547,6 +842,35 @@ pub fn parse_cli_command(args: &[String]) -> Option { _ => None, } } + "server" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::ServerList), + "set" => { + let id = args.get(pos + 3)?; + if id.is_empty() { + return None; + } + Some(CliCommand::ServerSet(id.clone())) + } + _ => None, + } + } + "search" => { + let scope_raw = args.get(pos + 2)?.as_str(); + let scope = match scope_raw { + "track" => SearchCliScope::Track, + "album" => SearchCliScope::Album, + "artist" => SearchCliScope::Artist, + _ => return None, + }; + let tail = args.get(pos + 3..)?; + let query = tail.join(" ").trim().to_string(); + if query.is_empty() { + return None; + } + Some(CliCommand::Search { scope, query }) + } _ => parse_player_cli_at(args, pos).map(CliCommand::Player), } } @@ -630,6 +954,26 @@ pub fn handle_cli_on_primary_instance(app: &AppHandle, argv: &[St let _ = app.emit("cli:library-set", folder.clone()); true } + Some(CliCommand::ServerList) => { + let _ = app.emit("cli:server-list", ()); + true + } + Some(CliCommand::ServerSet(id)) => { + let _ = app.emit("cli:server-set", id.clone()); + true + } + Some(CliCommand::Search { scope, query }) => { + let scope_s = match scope { + SearchCliScope::Track => "track", + SearchCliScope::Album => "album", + SearchCliScope::Artist => "artist", + }; + let _ = app.emit( + "cli:search", + serde_json::json!({ "scope": scope_s, "query": query }), + ); + true + } None => false, } } @@ -678,6 +1022,7 @@ pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { let _ = handle.emit("cli:instant-mix", s); } CliCommand::LibraryList => { + let _ = std::fs::remove_file(cli_library_response_path()); let _ = handle.emit("cli:library-list", ()); let text = read_library_cli_response_blocking(Duration::from_secs(3)); print_library_cli_stdout(&text, json_out); @@ -685,6 +1030,29 @@ pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { CliCommand::LibrarySet(folder) => { let _ = handle.emit("cli:library-set", folder.clone()); } + CliCommand::ServerList => { + let _ = std::fs::remove_file(cli_server_list_path()); + let _ = handle.emit("cli:server-list", ()); + let text = read_server_list_cli_response_blocking(Duration::from_secs(3)); + print_server_list_cli_stdout(&text, json_out); + } + CliCommand::ServerSet(id) => { + let _ = handle.emit("cli:server-set", id.clone()); + } + CliCommand::Search { scope, query } => { + let _ = std::fs::remove_file(cli_search_response_path()); + let scope_s = match scope { + SearchCliScope::Track => "track", + SearchCliScope::Album => "album", + SearchCliScope::Artist => "artist", + }; + let _ = handle.emit( + "cli:search", + serde_json::json!({ "scope": scope_s, "query": query }), + ); + let text = read_search_cli_response_blocking(Duration::from_secs(12)); + print_search_cli_stdout(&text, json_out); + } } if !quiet { println!("OK: {ok_line} (applied after startup)"); @@ -694,7 +1062,7 @@ pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { pub fn describe_cli_command(cmd: &CliCommand) -> String { match cmd { - CliCommand::Player(c) => describe_player_cli_cmd(*c), + CliCommand::Player(c) => describe_player_cli_cmd(c), CliCommand::AudioDeviceList => "audio-device list".into(), CliCommand::AudioDeviceSet(None) => "audio-device set default".into(), CliCommand::AudioDeviceSet(Some(s)) => format!("audio-device set {s}"), @@ -703,17 +1071,41 @@ pub fn describe_cli_command(cmd: &CliCommand) -> String { CliCommand::LibraryList => "library list".into(), CliCommand::LibrarySet(s) if s == "all" => "library set all".into(), CliCommand::LibrarySet(s) => format!("library set {s}"), + CliCommand::ServerList => "server list".into(), + CliCommand::ServerSet(s) => format!("server set {s}"), + CliCommand::Search { scope, query } => { + let sc = match scope { + SearchCliScope::Track => "track", + SearchCliScope::Album => "album", + SearchCliScope::Artist => "artist", + }; + format!("search {sc} {query}") + } } } -pub fn describe_player_cli_cmd(cmd: PlayerCliCmd) -> String { +pub fn describe_player_cli_cmd(cmd: &PlayerCliCmd) -> String { 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(), } } @@ -728,15 +1120,50 @@ pub fn emit_player_cli_cmd(app: &AppHandle, cmd: PlayerCliCmd) { 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", ()); + } PlayerCliCmd::Seek { delta_secs } => { let _ = app.emit("media:seek-relative", delta_secs); } PlayerCliCmd::Volume { percent } => { let _ = app.emit("media:set-volume", percent); } + PlayerCliCmd::ShuffleQueue => { + let _ = app.emit("cli:shuffle-queue", ()); + } + PlayerCliCmd::Repeat(mode) => { + let s = match mode { + RepeatCliMode::Off => "off", + 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); + } + PlayerCliCmd::Rating { stars } => { + let _ = app.emit("cli:set-rating-current", stars); + } + PlayerCliCmd::ReloadPlayer => { + let _ = app.emit("cli:reload-player", ()); + } } } @@ -771,6 +1198,12 @@ pub fn linux_try_forward_player_cli_secondary(args: &[String]) -> Result { let _ = std::fs::remove_file(cli_library_response_path()); } + Some(CliCommand::ServerList) => { + let _ = std::fs::remove_file(cli_server_list_path()); + } + Some(CliCommand::Search { .. }) => { + let _ = std::fs::remove_file(cli_search_response_path()); + } _ => {} } @@ -803,6 +1236,22 @@ pub fn linux_try_forward_player_cli_secondary(args: &[String]) -> Result Result<(), String> { crate::cli::write_library_cli_response(&payload) } +/// Writes `psysonic-cli-servers.json` for `psysonic --player server list`. +#[tauri::command] +fn cli_publish_server_list(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_server_list_cli_response(&payload) +} + +/// Writes `psysonic-cli-search.json` for `psysonic --player search …`. +#[tauri::command] +fn cli_publish_search_results(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_search_cli_response(&payload) +} + /// Toggle native window decorations at runtime (Linux custom title bar opt-out). #[tauri::command] fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { @@ -2109,6 +2121,30 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { .build(app) } +/// Creates the tray icon, or `None` if the OS cannot host one. +/// +/// On Linux, `libayatana-appindicator3` / `libappindicator3` may be absent (minimal +/// installs, wrong `LD_LIBRARY_PATH`). The `tray-icon` stack can **panic** on `dlopen` +/// failure instead of returning `Err`, so we catch unwind and keep the app running +/// (e.g. cold start with `--player` still works without tray libraries). +fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { + let app = app.clone(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) { + Ok(Ok(tray)) => Some(tray), + Ok(Err(e)) => { + eprintln!("[Psysonic] System tray unavailable: {e}"); + None + } + Err(_) => { + eprintln!( + "[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \ + (install the distro package or set LD_LIBRARY_PATH)" + ); + None + } + } +} + /// Show (`true`) or fully remove (`false`) the system-tray icon. /// /// The command is strictly idempotent: @@ -2132,7 +2168,12 @@ fn toggle_tray_icon( if guard.is_some() { return Ok(()); } - *guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?); + let Some(tray) = try_build_tray_icon(&app) else { + return Err( + "Tray icon could not be created (missing system libraries on Linux).".into(), + ); + }; + *guard = Some(tray); } else if let Some(tray) = guard.take() { // Hide synchronously before dropping so the OS processes the removal // before any subsequent show=true call can create a new icon. @@ -2267,11 +2308,13 @@ pub fn run() { } // ── System tray ─────────────────────────────────────────────── - // Always build on startup; the frontend calls toggle_tray_icon(false) + // Always build on startup when possible; the frontend calls toggle_tray_icon(false) // immediately after load if the user has disabled the tray icon. + // May be skipped if Ayatana/AppIndicator libraries are missing (no panic). { - let tray = build_tray_icon(app.handle())?; - *app.state::().lock().unwrap() = Some(tray); + if let Some(tray) = try_build_tray_icon(app.handle()) { + *app.state::().lock().unwrap() = Some(tray); + } } // ── MPRIS2 / OS media controls via souvlaki ────────────────── @@ -2415,6 +2458,8 @@ pub fn run() { exit_app, cli_publish_player_snapshot, cli_publish_library_list, + cli_publish_server_list, + cli_publish_search_results, set_window_decorations, no_compositing_mode, is_tiling_wm_cmd, diff --git a/src/App.tsx b/src/App.tsx index 35efc81d..367cd9ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,10 +58,21 @@ import { IS_LINUX } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; -import { getMusicFolders, getSimilarSongs, probeEntityRatingSupport } from './api/subsonic'; +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, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore'; import { useThemeStore } from './store/themeStore'; import { useThemeScheduler } from './hooks/useThemeScheduler'; @@ -72,6 +83,9 @@ import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; import { useZipDownloadStore } from './store/zipDownloadStore'; import ZipDownloadOverlay from './components/ZipDownloadOverlay'; +/** Volume before last `psysonic --player mute` (CLI only; in-memory). */ +let cliPremuteVolume: number | null = null; + function RequireAuth({ children }: { children: React.ReactNode }) { const { isLoggedIn, servers, activeServerId } = useAuthStore(); if (!isLoggedIn || !activeServerId || servers.length === 0) return ; @@ -544,6 +558,171 @@ function TauriEventBridge() { }; }, []); + // CLI: servers, search, transport extras, mute, star, rating, play-by-id, reload. + useEffect(() => { + const unsubs: Array<() => void> = []; + listen('cli:server-list', async () => { + const auth = useAuthStore.getState(); + await invoke('cli_publish_server_list', { + payload: { + active_server_id: auth.activeServerId, + servers: auth.servers.map(s => ({ id: s.id, name: s.name })), + }, + }); + }).then(u => unsubs.push(u)); + listen('cli:server-set', async e => { + const raw = typeof e.payload === 'string' ? e.payload : ''; + const id = raw.trim(); + if (!id) return; + const server = useAuthStore.getState().servers.find(s => s.id === id); + if (!server) { + showToast(i18n.t('contextMenu.cliServerNotFound', { defaultValue: 'Server id not found.' }), 4000, 'error'); + return; + } + const ok = await switchActiveServer(server); + if (!ok) { + showToast(i18n.t('contextMenu.cliServerSwitchFailed', { defaultValue: 'Could not switch server (ping failed).' }), 5000, 'error'); + } + }).then(u => unsubs.push(u)); + listen<{ scope: string; query: string }>('cli:search', async e => { + const { scope, query } = e.payload; + const base = { scope, query, ready: false }; + try { + const r = await subsonicSearch(query, { songCount: 50, albumCount: 30, artistCount: 30 }); + const payload = + scope === 'track' + ? { + ...base, + songs: r.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist })), + albums: [] as { id: string; name: string; artist: string }[], + artists: [] as { id: string; name: string }[], + ready: true, + } + : scope === 'album' + ? { + ...base, + songs: [] as { id: string; title: string; artist: string }[], + albums: r.albums.map(a => ({ id: a.id, name: a.name, artist: a.artist })), + artists: [] as { id: string; name: string }[], + ready: true, + } + : { + ...base, + songs: [] as { id: string; title: string; artist: string }[], + albums: [] as { id: string; name: string; artist: string }[], + artists: r.artists.map(a => ({ id: a.id, name: a.name })), + ready: true, + }; + await invoke('cli_publish_search_results', { payload }); + } catch (err) { + console.error('CLI search failed', err); + await invoke('cli_publish_search_results', { + payload: { + ...base, + songs: [], + albums: [], + artists: [], + ready: true, + error: err instanceof Error ? err.message : 'search failed', + }, + }).catch(() => {}); + } + }).then(u => unsubs.push(u)); + listen('cli:play-id', async e => { + const id = typeof e.payload === 'string' ? e.payload.trim() : ''; + if (!id) return; + try { + await playByOpaqueId(id); + } catch (err) { + console.error('CLI play failed', err); + const notFound = err instanceof Error && err.message === 'play_by_id_not_found'; + showToast( + i18n.t('contextMenu.cliPlayIdNotFound', { + defaultValue: notFound + ? 'No song, album, or artist matches this id.' + : 'Could not start playback.', + }), + 5000, + 'error', + ); + } + }).then(u => unsubs.push(u)); + listen('cli:shuffle-queue', () => { + usePlayerStore.getState().shuffleQueue(); + }).then(u => unsubs.push(u)); + listen('cli:set-repeat', e => { + const m = typeof e.payload === 'string' ? e.payload : ''; + const mode = m === 'all' ? 'all' : m === 'one' ? 'one' : 'off'; + usePlayerStore.setState({ repeatMode: mode }); + }).then(u => unsubs.push(u)); + listen('cli:mute', () => { + const { volume, setVolume } = usePlayerStore.getState(); + if (volume > 0) cliPremuteVolume = volume; + setVolume(0); + }).then(u => unsubs.push(u)); + listen('cli:unmute', () => { + const restore = cliPremuteVolume ?? 0.8; + cliPremuteVolume = null; + usePlayerStore.getState().setVolume(restore); + }).then(u => unsubs.push(u)); + listen('cli:star-current', async e => { + const want = e.payload === true; + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + try { + if (want) { + await star(track.id, 'song'); + usePlayerStore.getState().setStarredOverride(track.id, true); + } else { + await unstar(track.id, 'song'); + usePlayerStore.getState().setStarredOverride(track.id, false); + } + } catch (err) { + console.error('CLI star failed', err); + showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error'); + } + }).then(u => unsubs.push(u)); + listen('cli:set-rating-current', async e => { + const stars = e.payload; + if (typeof stars !== 'number' || Number.isNaN(stars) || stars < 0 || stars > 5) return; + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + try { + await setRating(track.id, stars); + usePlayerStore.getState().setUserRatingOverride(track.id, stars); + } catch (err) { + console.error('CLI set rating failed', err); + } + }).then(u => unsubs.push(u)); + listen('cli:reload-player', async () => { + const store = usePlayerStore.getState(); + const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; + stop(); + resetAudioPause(); + await invoke('audio_stop').catch(() => {}); + if (currentTrack) { + try { + const fresh = await getSong(currentTrack.id); + const t = fresh ? songToTrack(fresh) : currentTrack; + playTrack(t, queue, true); + } catch { + playTrack(currentTrack, queue, true); + } + } else { + await initializeFromServerQueue(); + } + }).then(u => unsubs.push(u)); + return () => { + unsubs.forEach(u => u()); + }; + }, []); + // Sync tray-icon visibility with the user's stored setting. // Runs once on mount (initial sync) and again whenever the setting changes. const showTrayIcon = useAuthStore(s => s.showTrayIcon); @@ -622,6 +801,7 @@ function TauriEventBridge() { ['tray:play-pause', () => togglePlay()], ['tray:next', () => next()], ['tray:previous', () => previous()], + ['media:stop', () => 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)); }], ]; @@ -687,6 +867,13 @@ function TauriEventBridge() { const auth = useAuthStore.getState(); const sid = auth.activeServerId; const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all'; + const ct = s.currentTrack; + const currentTrackUserRating = + ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null; + const currentTrackStarred = + ct != null + ? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred)) + : null; const snapshot = { current_track: s.currentTrack, current_radio: s.currentRadio, @@ -696,6 +883,10 @@ function TauriEventBridge() { is_playing: s.isPlaying, current_time: s.currentTime, volume: s.volume, + repeat_mode: s.repeatMode, + current_track_user_rating: currentTrackUserRating, + current_track_starred: currentTrackStarred, + servers: auth.servers.map(({ id, name }) => ({ id, name })), music_library: { active_server_id: sid, selected, diff --git a/src/utils/playArtistShuffled.ts b/src/utils/playArtistShuffled.ts new file mode 100644 index 00000000..8989160a --- /dev/null +++ b/src/utils/playArtistShuffled.ts @@ -0,0 +1,25 @@ +import { getAlbum, getArtist } from '../api/subsonic'; +import { shuffleArray, songToTrack, usePlayerStore } from '../store/playerStore'; + +/** + * All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”. + */ +export async function playArtistShuffled(artistId: string): Promise { + const { albums } = await getArtist(artistId); + if (albums.length === 0) { + throw new Error('play_artist_no_tracks'); + } + + const results = await Promise.all(albums.map(a => getAlbum(a.id))); + const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); + const tracks = sorted.flatMap(r => + [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack), + ); + + if (tracks.length === 0) { + throw new Error('play_artist_no_tracks'); + } + + const shuffled = shuffleArray(tracks); + usePlayerStore.getState().playTrack(shuffled[0], shuffled); +} diff --git a/src/utils/playByOpaqueId.ts b/src/utils/playByOpaqueId.ts new file mode 100644 index 00000000..ff065e81 --- /dev/null +++ b/src/utils/playByOpaqueId.ts @@ -0,0 +1,35 @@ +import { getAlbum, getSong } from '../api/subsonic'; +import { playAlbum } from './playAlbum'; +import { playArtistShuffled } from './playArtistShuffled'; +import { songToTrack, usePlayerStore } from '../store/playerStore'; + +/** + * `getSong` → `getAlbum` → `getArtist`: one opaque Subsonic id may refer to a track, + * album, or artist depending on the server. + */ +export async function playByOpaqueId(id: string): Promise { + const trimmed = id.trim(); + if (!trimmed) return; + + const song = await getSong(trimmed); + if (song) { + usePlayerStore.getState().playTrack(songToTrack(song)); + return; + } + + try { + const { songs } = await getAlbum(trimmed); + if (songs.length > 0) { + await playAlbum(trimmed); + return; + } + } catch { + /* not an album */ + } + + try { + await playArtistShuffled(trimmed); + } catch { + throw new Error('play_by_id_not_found'); + } +}