mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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.
This commit is contained in:
@@ -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`**.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+1
@@ -3615,6 +3615,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
|
"zbus 5.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ symphonia-adapter-libopus = "0.2.7"
|
|||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.58", features = [
|
windows = { version = "0.58", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
|
|||||||
+12
-7
@@ -3026,6 +3026,17 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti
|
|||||||
Some(canon)
|
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<String> {
|
||||||
|
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.
|
/// 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
|
/// 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.
|
/// 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`.
|
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||||
let mut list = enumerate_output_device_names();
|
audio_list_devices_for_engine(&state)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||||
|
|||||||
@@ -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<String>),
|
||||||
|
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<i32> {
|
||||||
|
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 <seconds> 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 <device-id|default>\n");
|
||||||
|
eprintln!(" Music library (Subsonic music folders for the active server)");
|
||||||
|
eprintln!(" {program} [--json] --player library list");
|
||||||
|
eprintln!(" {program} --player library set all | <folder-id>\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::<Value>(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::<Value>(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<String> = 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<bool, String> {
|
||||||
|
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::<bool>()
|
||||||
|
.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<bool, String> {
|
||||||
|
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<String, Value>) -> 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<PlayerCliCmd> {
|
||||||
|
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<CliCommand> {
|
||||||
|
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<R: Runtime>(app: &AppHandle<R>, 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::<crate::audio::AudioEngine>() {
|
||||||
|
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<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
let argv: Vec<String> = 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::<crate::audio::AudioEngine>() {
|
||||||
|
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::<Value>(&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<R: Runtime>(app: &AppHandle<R>, 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<LinuxPlayerForwardResult, String> {
|
||||||
|
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::<Value>(&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)
|
||||||
|
}
|
||||||
+38
-1
@@ -2,6 +2,7 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
pub mod cli;
|
||||||
mod discord;
|
mod discord;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod taskbar_win;
|
mod taskbar_win;
|
||||||
@@ -45,6 +46,18 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
|||||||
app_handle.exit(0);
|
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).
|
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||||
@@ -2199,6 +2212,23 @@ fn is_tiling_wm_cmd() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
let argv: Vec<String> = 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();
|
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -2214,11 +2244,13 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.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 = app.get_webview_window("main").expect("no main window");
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -2350,6 +2382,9 @@ pub fn run() {
|
|||||||
audio::start_device_watcher(&engine, app.handle().clone());
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
@@ -2378,6 +2413,8 @@ pub fn run() {
|
|||||||
greet,
|
greet,
|
||||||
calculate_sync_payload,
|
calculate_sync_payload,
|
||||||
exit_app,
|
exit_app,
|
||||||
|
cli_publish_player_snapshot,
|
||||||
|
cli_publish_library_list,
|
||||||
set_window_decorations,
|
set_window_decorations,
|
||||||
no_compositing_mode,
|
no_compositing_mode,
|
||||||
is_tiling_wm_cmd,
|
is_tiling_wm_cmd,
|
||||||
|
|||||||
@@ -13,5 +13,24 @@ fn main() {
|
|||||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let args: Vec<String> = 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();
|
psysonic_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-2
@@ -58,10 +58,11 @@ import { IS_LINUX } from './utils/platform';
|
|||||||
import { version } from '../package.json';
|
import { version } from '../package.json';
|
||||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
import { getMusicFolders, getSimilarSongs, probeEntityRatingSupport } from './api/subsonic';
|
||||||
import { useOfflineStore } from './store/offlineStore';
|
import { useOfflineStore } from './store/offlineStore';
|
||||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
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 { useThemeStore } from './store/themeStore';
|
||||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||||
import { useFontStore } from './store/fontStore';
|
import { useFontStore } from './store/fontStore';
|
||||||
@@ -453,6 +454,96 @@ function TauriEventBridge() {
|
|||||||
return () => { unlisten?.(); };
|
return () => { unlisten?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// CLI: `--player audio-device set …` (forwarded on Linux via single-instance).
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
listen<string>('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<string>('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<string>('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.
|
// Sync tray-icon visibility with the user's stored setting.
|
||||||
// Runs once on mount (initial sync) and again whenever the setting changes.
|
// Runs once on mount (initial sync) and again whenever the setting changes.
|
||||||
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
|
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
|
||||||
@@ -524,6 +615,8 @@ function TauriEventBridge() {
|
|||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const handlers: Array<[string, () => void]> = [
|
const handlers: Array<[string, () => void]> = [
|
||||||
['media:play-pause', () => togglePlay()],
|
['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:next', () => next()],
|
||||||
['media:prev', () => previous()],
|
['media:prev', () => previous()],
|
||||||
['tray:play-pause', () => togglePlay()],
|
['tray:play-pause', () => togglePlay()],
|
||||||
@@ -559,6 +652,15 @@ function TauriEventBridge() {
|
|||||||
if (cancelled) { u(); return; }
|
if (cancelled) { u(); return; }
|
||||||
unlisten.push(u);
|
unlisten.push(u);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
const u = await listen<number>('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).
|
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||||
// JS decides: minimize to tray or exit, based on user setting.
|
// JS decides: minimize to tray or exit, based on user setting.
|
||||||
@@ -577,6 +679,48 @@ function TauriEventBridge() {
|
|||||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||||
}, [togglePlay, next, previous]);
|
}, [togglePlay, next, previous]);
|
||||||
|
|
||||||
|
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
|
||||||
|
useEffect(() => {
|
||||||
|
let tid: ReturnType<typeof setTimeout> | 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
|
import { useAuthStore, type ServerProfile } from '../store/authStore';
|
||||||
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
|
import { showToast } from '../utils/toast';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
@@ -11,28 +16,141 @@ interface Props {
|
|||||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const servers = useAuthStore(s => s.servers);
|
||||||
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
||||||
|
const hostRef = useRef<HTMLDivElement>(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 label = isLan ? 'LAN' : t('connection.extern');
|
||||||
const tooltip =
|
const tooltip = multi
|
||||||
status === 'connected'
|
? t('connection.switchServerHint')
|
||||||
|
: status === 'connected'
|
||||||
? t('connection.connectedTo', { server: serverName })
|
? t('connection.connectedTo', { server: serverName })
|
||||||
: status === 'disconnected'
|
: status === 'disconnected'
|
||||||
? t('connection.disconnectedFrom', { server: serverName })
|
? t('connection.disconnectedFrom', { server: serverName })
|
||||||
: t('connection.checking');
|
: t('connection.checking');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="connection-indicator-host" ref={hostRef}>
|
||||||
<div
|
<div
|
||||||
className="connection-indicator"
|
className="connection-indicator"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
onClick={onTriggerClick}
|
||||||
data-tooltip={tooltip}
|
data-tooltip={tooltip}
|
||||||
data-tooltip-pos="bottom"
|
data-tooltip-pos="bottom"
|
||||||
|
role={multi ? 'button' : undefined}
|
||||||
|
aria-haspopup={multi ? 'menu' : undefined}
|
||||||
|
aria-expanded={multi ? menuOpen : undefined}
|
||||||
>
|
>
|
||||||
<div className={`connection-led connection-led--${status}`} />
|
<div className={`connection-led connection-led--${status}`} />
|
||||||
<div className="connection-meta">
|
<div className="connection-meta">
|
||||||
<span className="connection-type">{label}</span>
|
<span className="connection-type">{label}</span>
|
||||||
<span className="connection-server">{serverName}</span>
|
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
||||||
|
{multi && (
|
||||||
|
<ChevronDown size={12} className={menuOpen ? 'connection-indicator-chevron--open' : undefined} style={{ flexShrink: 0, opacity: 0.85 }} aria-hidden />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{multi && menuOpen && (
|
||||||
|
<div
|
||||||
|
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
|
||||||
|
role="menu"
|
||||||
|
aria-label={t('connection.switchServerTitle')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
padding: '6px 10px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('connection.switchServerTitle')}
|
||||||
|
</div>
|
||||||
|
{servers.map(srv => {
|
||||||
|
const active = srv.id === activeServerId;
|
||||||
|
const busy = switchingId !== null;
|
||||||
|
const labelText = (srv.name || srv.url).trim() || srv.url;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={srv.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onPickServer(srv)}
|
||||||
|
>
|
||||||
|
<span className="nav-library-dropdown-item-label">{labelText}</span>
|
||||||
|
{switchingId === srv.id ? (
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
|
||||||
|
) : active ? (
|
||||||
|
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)', marginTop: 2, paddingTop: 2 }} />
|
||||||
|
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
|
||||||
|
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
|
||||||
|
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1344,12 +1344,7 @@ export default function ContextMenu() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startInstantMix = async (song: Track) => {
|
const startInstantMix = async (song: Track) => {
|
||||||
const state = usePlayerStore.getState();
|
usePlayerStore.getState().reseedQueueForInstantMix(song);
|
||||||
if (state.currentTrack?.id === song.id) {
|
|
||||||
if (!state.isPlaying) state.resume();
|
|
||||||
} else {
|
|
||||||
playTrack(song, [song]);
|
|
||||||
}
|
|
||||||
const serverId = useAuthStore.getState().activeServerId;
|
const serverId = useAuthStore.getState().activeServerId;
|
||||||
try {
|
try {
|
||||||
const similar = await getSimilarSongs(song.id, 50);
|
const similar = await getSimilarSongs(song.id, 50);
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const deTranslation = {
|
|||||||
startRadio: 'Radio starten',
|
startRadio: 'Radio starten',
|
||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
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',
|
lfmLove: 'Auf Last.fm liken',
|
||||||
lfmUnlove: 'Last.fm-Like entfernen',
|
lfmUnlove: 'Last.fm-Like entfernen',
|
||||||
favorite: 'Favorisieren',
|
favorite: 'Favorisieren',
|
||||||
@@ -355,6 +356,10 @@ export const deTranslation = {
|
|||||||
offlineFilterArtists: 'Diskografien',
|
offlineFilterArtists: 'Diskografien',
|
||||||
retry: 'Erneut versuchen',
|
retry: 'Erneut versuchen',
|
||||||
serverSettings: 'Server-Einstellungen',
|
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}}',
|
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const enTranslation = {
|
|||||||
startRadio: 'Start Radio',
|
startRadio: 'Start Radio',
|
||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
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',
|
lfmLove: 'Love on Last.fm',
|
||||||
lfmUnlove: 'Unlove on Last.fm',
|
lfmUnlove: 'Unlove on Last.fm',
|
||||||
favorite: 'Favorite',
|
favorite: 'Favorite',
|
||||||
@@ -356,6 +357,10 @@ export const enTranslation = {
|
|||||||
offlineFilterArtists: 'Discographies',
|
offlineFilterArtists: 'Discographies',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
serverSettings: 'Server Settings',
|
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}}',
|
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const esTranslation = {
|
|||||||
startRadio: 'Iniciar Radio',
|
startRadio: 'Iniciar Radio',
|
||||||
instantMix: 'Mezcla Instantánea',
|
instantMix: 'Mezcla Instantánea',
|
||||||
instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.',
|
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',
|
lfmLove: 'Favorito en Last.fm',
|
||||||
lfmUnlove: 'Quitar favorito de Last.fm',
|
lfmUnlove: 'Quitar favorito de Last.fm',
|
||||||
favorite: 'Favorito',
|
favorite: 'Favorito',
|
||||||
@@ -357,6 +358,10 @@ export const esTranslation = {
|
|||||||
offlineFilterArtists: 'Discografías',
|
offlineFilterArtists: 'Discografías',
|
||||||
retry: 'Reintentar',
|
retry: 'Reintentar',
|
||||||
serverSettings: 'Configuración del servidor',
|
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}}',
|
lastfmConnected: 'Last.fm conectado como @{{user}}',
|
||||||
lastfmSessionInvalid: 'Sesión inválida — click para reconectar',
|
lastfmSessionInvalid: 'Sesión inválida — click para reconectar',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const frTranslation = {
|
|||||||
startRadio: 'Démarrer la radio',
|
startRadio: 'Démarrer la radio',
|
||||||
instantMix: 'Mix instantané',
|
instantMix: 'Mix instantané',
|
||||||
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
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',
|
lfmLove: 'Aimer sur Last.fm',
|
||||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||||
favorite: 'Favori',
|
favorite: 'Favori',
|
||||||
@@ -355,6 +356,10 @@ export const frTranslation = {
|
|||||||
offlineFilterArtists: 'Discographies',
|
offlineFilterArtists: 'Discographies',
|
||||||
retry: 'Réessayer',
|
retry: 'Réessayer',
|
||||||
serverSettings: 'Paramètres serveur',
|
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}}',
|
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const nbTranslation = {
|
|||||||
startRadio: 'Start radio',
|
startRadio: 'Start radio',
|
||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
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',
|
lfmLove: 'Lik på Last.fm',
|
||||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||||
favorite: 'Favoritt',
|
favorite: 'Favoritt',
|
||||||
@@ -355,6 +356,10 @@ export const nbTranslation = {
|
|||||||
offlineFilterArtists: 'Diskografier',
|
offlineFilterArtists: 'Diskografier',
|
||||||
retry: 'Prøv igjen',
|
retry: 'Prøv igjen',
|
||||||
serverSettings: 'Serverinnstillinger',
|
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}}',
|
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
|
||||||
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
|
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const nlTranslation = {
|
|||||||
startRadio: 'Radio starten',
|
startRadio: 'Radio starten',
|
||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
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',
|
lfmLove: 'Liken op Last.fm',
|
||||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||||
favorite: 'Favoriet',
|
favorite: 'Favoriet',
|
||||||
@@ -354,6 +355,10 @@ export const nlTranslation = {
|
|||||||
offlineFilterArtists: 'Discografieën',
|
offlineFilterArtists: 'Discografieën',
|
||||||
retry: 'Opnieuw proberen',
|
retry: 'Opnieuw proberen',
|
||||||
serverSettings: 'Serverinstellingen',
|
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}}',
|
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const ruTranslation = {
|
|||||||
startRadio: 'Радио по похожим',
|
startRadio: 'Радио по похожим',
|
||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||||
|
cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.',
|
||||||
lfmLove: 'Любимое на Last.fm',
|
lfmLove: 'Любимое на Last.fm',
|
||||||
lfmUnlove: 'Убрать с Last.fm',
|
lfmUnlove: 'Убрать с Last.fm',
|
||||||
favorite: 'В избранное',
|
favorite: 'В избранное',
|
||||||
@@ -369,6 +370,10 @@ export const ruTranslation = {
|
|||||||
offlineFilterArtists: 'Дискографии',
|
offlineFilterArtists: 'Дискографии',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
serverSettings: 'Настройки сервера',
|
serverSettings: 'Настройки сервера',
|
||||||
|
switchServerTitle: 'Сменить сервер',
|
||||||
|
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
||||||
|
manageServers: 'Управление серверами…',
|
||||||
|
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
||||||
lastfmConnected: 'Last.fm: @{{user}}',
|
lastfmConnected: 'Last.fm: @{{user}}',
|
||||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const zhTranslation = {
|
|||||||
startRadio: '开始电台',
|
startRadio: '开始电台',
|
||||||
instantMix: '即时混音',
|
instantMix: '即时混音',
|
||||||
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||||
|
cliMixNeedsTrack: '当前没有正在播放的内容 — 请先开始播放,然后再执行混音命令。',
|
||||||
lfmLove: '在 Last.fm 上标记喜欢',
|
lfmLove: '在 Last.fm 上标记喜欢',
|
||||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||||
favorite: '收藏',
|
favorite: '收藏',
|
||||||
@@ -350,6 +351,10 @@ export const zhTranslation = {
|
|||||||
offlineAlbumCount_plural: '{{n}} 张专辑',
|
offlineAlbumCount_plural: '{{n}} 张专辑',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
serverSettings: '服务器设置',
|
serverSettings: '服务器设置',
|
||||||
|
switchServerTitle: '切换服务器',
|
||||||
|
switchServerHint: '点击选择其他已保存的服务器。',
|
||||||
|
manageServers: '管理服务器…',
|
||||||
|
switchFailed: '无法切换 — 无法连接服务器。',
|
||||||
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
|
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
|
||||||
lastfmSessionInvalid: '会话无效 — 点击重新连接',
|
lastfmSessionInvalid: '会话无效 — 点击重新连接',
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-15
@@ -33,6 +33,7 @@ import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
|||||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import Equalizer from '../components/Equalizer';
|
import Equalizer from '../components/Equalizer';
|
||||||
@@ -572,25 +573,13 @@ export default function Settings() {
|
|||||||
|
|
||||||
const switchToServer = async (server: ServerProfile) => {
|
const switchToServer = async (server: ServerProfile) => {
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||||
try {
|
const ok = await switchActiveServer(server);
|
||||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
if (ok) {
|
||||||
if (ping.ok) {
|
setConnStatus(s => ({ ...s, [server.id]: '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('/');
|
navigate('/');
|
||||||
} else {
|
} else {
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteServer = (server: ServerProfile) => {
|
const deleteServer = (server: ServerProfile) => {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function songToTrack(song: SubsonicSong): Track {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffleArray<T>(items: T[]): T[] {
|
export function shuffleArray<T>(items: T[]): T[] {
|
||||||
const arr = [...items];
|
const arr = [...items];
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
@@ -131,6 +131,8 @@ interface PlayerState {
|
|||||||
|
|
||||||
playRadio: (station: InternetRadioStation) => void;
|
playRadio: (station: InternetRadioStation) => void;
|
||||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => 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;
|
pause: () => void;
|
||||||
resume: () => void;
|
resume: () => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
@@ -942,6 +944,22 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
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 / resume / togglePlay ──────────────────────────────────────────
|
||||||
pause: () => {
|
pause: () => {
|
||||||
if (get().currentRadio) {
|
if (get().currentRadio) {
|
||||||
|
|||||||
@@ -4090,6 +4090,24 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─ Connection Indicator ─ */
|
/* ─ 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 {
|
.connection-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user