mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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 <id>` 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.
This commit is contained in:
+44
-1
@@ -39,6 +39,25 @@ _psysonic_library_folder_ids() {
|
|||||||
(( $#ids )) && compadd -a 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() {
|
_psysonic_globals() {
|
||||||
compadd -J options -X 'option' -- \
|
compadd -J options -X 'option' -- \
|
||||||
--help --version --info --json --quiet --player completions
|
--help --version --info --json --quiet --player completions
|
||||||
@@ -68,7 +87,8 @@ fi
|
|||||||
integer n=${#sub[@]}
|
integer n=${#sub[@]}
|
||||||
if (( n == 0 )); then
|
if (( n == 0 )); then
|
||||||
compadd -J verbs -X 'player command' -- \
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -92,10 +112,33 @@ case ${sub[1]} in
|
|||||||
mix)
|
mix)
|
||||||
(( n == 1 )) && compadd append new
|
(( 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)
|
seek)
|
||||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||||
;;
|
;;
|
||||||
volume)
|
volume)
|
||||||
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
||||||
;;
|
;;
|
||||||
|
play)
|
||||||
|
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
|
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
|
||||||
#
|
#
|
||||||
# Uses no `mapfile` so bash 3.2 (macOS default) works.
|
# 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() {
|
_psysonic_compreply_from_compgen() {
|
||||||
# $1 = compgen -W word list, $2 = current word
|
# $1 = compgen -W word list, $2 = current word
|
||||||
@@ -33,6 +41,16 @@ _psysonic_library_json() {
|
|||||||
[[ -r $f ]] && printf '%s' "$f"
|
[[ -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() {
|
_psysonic_complete() {
|
||||||
local cur
|
local cur
|
||||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||||
@@ -58,7 +76,7 @@ _psysonic_complete() {
|
|||||||
local n=${#sub[@]}
|
local n=${#sub[@]}
|
||||||
|
|
||||||
if (( n == 0 )); then
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -78,7 +96,7 @@ _psysonic_complete() {
|
|||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||||
done < <(compgen -W 'default' -- "$cur")
|
done < <(compgen -W 'default' -- "$cur")
|
||||||
((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null
|
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
library)
|
library)
|
||||||
@@ -96,14 +114,52 @@ _psysonic_complete() {
|
|||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ -n $line ]] && COMPREPLY+=("$line")
|
[[ -n $line ]] && COMPREPLY+=("$line")
|
||||||
done < <(compgen -W 'all' -- "$cur")
|
done < <(compgen -W 'all' -- "$cur")
|
||||||
((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null
|
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
mix)
|
mix)
|
||||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
|
(( 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)
|
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
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1473,7 +1473,7 @@ pub struct AudioEngine {
|
|||||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||||
pub eq_enabled: Arc<AtomicBool>,
|
pub eq_enabled: Arc<AtomicBool>,
|
||||||
pub eq_pre_gain: Arc<AtomicU32>,
|
pub eq_pre_gain: Arc<AtomicU32>,
|
||||||
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
pub crossfade_enabled: Arc<AtomicBool>,
|
pub crossfade_enabled: Arc<AtomicBool>,
|
||||||
pub crossfade_secs: Arc<AtomicU32>,
|
pub crossfade_secs: Arc<AtomicU32>,
|
||||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||||
@@ -1482,7 +1482,7 @@ pub struct AudioEngine {
|
|||||||
pub gapless_enabled: Arc<AtomicBool>,
|
pub gapless_enabled: Arc<AtomicBool>,
|
||||||
/// Info about the next-up chained track (gapless mode).
|
/// Info about the next-up chained track (gapless mode).
|
||||||
/// The progress task reads this when `current_source_done` fires.
|
/// The progress task reads this when `current_source_done` fires.
|
||||||
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||||
/// Atomic sample counter — incremented by CountingSource in the audio thread.
|
/// Atomic sample counter — incremented by CountingSource in the audio thread.
|
||||||
/// Progress task reads this for drift-free position tracking.
|
/// Progress task reads this for drift-free position tracking.
|
||||||
pub samples_played: Arc<AtomicU64>,
|
pub samples_played: Arc<AtomicU64>,
|
||||||
@@ -1495,7 +1495,7 @@ pub struct AudioEngine {
|
|||||||
pub gapless_switch_at: Arc<AtomicU64>,
|
pub gapless_switch_at: Arc<AtomicU64>,
|
||||||
/// Active radio session state. None for regular (non-radio) tracks.
|
/// Active radio session state. None for regular (non-radio) tracks.
|
||||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||||
pub radio_state: Mutex<Option<RadioLiveState>>,
|
pub(crate) radio_state: Mutex<Option<RadioLiveState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioCurrent {
|
pub struct AudioCurrent {
|
||||||
|
|||||||
+456
-7
@@ -12,13 +12,37 @@ use serde_json::Value;
|
|||||||
use tauri::{AppHandle, Emitter, Runtime};
|
use tauri::{AppHandle, Emitter, Runtime};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[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 {
|
pub enum PlayerCliCmd {
|
||||||
Next,
|
Next,
|
||||||
Prev,
|
Prev,
|
||||||
Play,
|
Play,
|
||||||
|
PlayOpaqueId(String),
|
||||||
Pause,
|
Pause,
|
||||||
|
Stop,
|
||||||
Seek { delta_secs: i32 },
|
Seek { delta_secs: i32 },
|
||||||
Volume { percent: u8 },
|
Volume { percent: u8 },
|
||||||
|
ShuffleQueue,
|
||||||
|
Repeat(RepeatCliMode),
|
||||||
|
Mute,
|
||||||
|
Unmute,
|
||||||
|
StarCurrent,
|
||||||
|
UnstarCurrent,
|
||||||
|
Rating { stars: u8 },
|
||||||
|
ReloadPlayer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -37,6 +61,12 @@ pub enum CliCommand {
|
|||||||
/// `"all"` or a music-folder id from `library list`.
|
/// `"all"` or a music-folder id from `library list`.
|
||||||
LibrarySet(String),
|
LibrarySet(String),
|
||||||
Mix(MixCliMode),
|
Mix(MixCliMode),
|
||||||
|
ServerList,
|
||||||
|
ServerSet(String),
|
||||||
|
Search {
|
||||||
|
scope: SearchCliScope,
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn wants_version(args: &[String]) -> bool {
|
pub fn wants_version(args: &[String]) -> bool {
|
||||||
@@ -63,7 +93,7 @@ pub fn wants_quiet(args: &[String]) -> bool {
|
|||||||
.any(|a| a == "--quiet" || a == "-q")
|
.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 {
|
pub fn wants_cli_json_output(args: &[String]) -> bool {
|
||||||
args.iter().skip(1).any(|a| a == "--json")
|
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")
|
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.
|
/// `psysonic completions …` — returns exit code when this argv should not start the GUI.
|
||||||
pub fn try_completions_dispatch(args: &[String]) -> Option<i32> {
|
pub fn try_completions_dispatch(args: &[String]) -> Option<i32> {
|
||||||
if args.get(1).map(|s| s.as_str()) != Some("completions") {
|
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!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n");
|
||||||
eprintln!(" Global flags (place before --player when needed):");
|
eprintln!(" Global flags (place before --player when needed):");
|
||||||
eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown).");
|
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!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n");
|
||||||
eprintln!(" Playback");
|
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 <id> Track, album, or artist id (artist → shuffled library).");
|
||||||
eprintln!(" {program} [--quiet|-q] --player seek <seconds> Integer delta, e.g. 15 or -10");
|
eprintln!(" {program} [--quiet|-q] --player seek <seconds> 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!(" Audio output");
|
||||||
eprintln!(" {program} [--json] --player audio-device list");
|
eprintln!(" {program} [--json] --player audio-device list");
|
||||||
eprintln!(" {program} --player audio-device set <device-id|default>\n");
|
eprintln!(" {program} --player audio-device set <device-id|default>\n");
|
||||||
eprintln!(" Music library (Subsonic music folders for the active server)");
|
eprintln!(" Music library (Subsonic music folders for the active server)");
|
||||||
eprintln!(" {program} [--json] --player library list");
|
eprintln!(" {program} [--json] --player library list");
|
||||||
eprintln!(" {program} --player library set all | <folder-id>\n");
|
eprintln!(" {program} --player library set all | <folder-id>\n");
|
||||||
|
eprintln!(" Servers (saved profiles — same as the in-app server switcher)");
|
||||||
|
eprintln!(" {program} [--json] --player server list");
|
||||||
|
eprintln!(" {program} --player server set <server-id>\n");
|
||||||
|
eprintln!(" Search (active server; respects library folder filter)");
|
||||||
|
eprintln!(" {program} [--json] --player search track <query…>");
|
||||||
|
eprintln!(" {program} [--json] --player search album <query…>");
|
||||||
|
eprintln!(" {program} [--json] --player search artist <query…>\n");
|
||||||
eprintln!(" Instant mix (from the track that is currently loaded)");
|
eprintln!(" Instant mix (from the track that is currently loaded)");
|
||||||
eprintln!(" {program} --player mix append");
|
eprintln!(" {program} --player mix append");
|
||||||
eprintln!(" {program} --player mix new\n");
|
eprintln!(" {program} --player mix new\n");
|
||||||
@@ -275,6 +337,186 @@ pub fn write_library_cli_response(payload: &Value) -> Result<(), String> {
|
|||||||
Ok(())
|
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::<Value>(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::<Value>(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::<Value>(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::<Value>(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 {
|
fn tauri_identifier() -> &'static str {
|
||||||
static ID: OnceLock<String> = OnceLock::new();
|
static ID: OnceLock<String> = OnceLock::new();
|
||||||
ID.get_or_init(|| {
|
ID.get_or_init(|| {
|
||||||
@@ -439,12 +681,28 @@ fn print_info_human(v: &Value) {
|
|||||||
"volume",
|
"volume",
|
||||||
"queue_index",
|
"queue_index",
|
||||||
"queue_length",
|
"queue_length",
|
||||||
|
"repeat_mode",
|
||||||
|
"current_track_user_rating",
|
||||||
|
"current_track_starred",
|
||||||
] {
|
] {
|
||||||
if let Some(val) = o.get(key) {
|
if let Some(val) = o.get(key) {
|
||||||
println!(" {key}: {}", value_inline(val));
|
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));
|
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") {
|
if let Some(Value::Array(items)) = o.get("queue") {
|
||||||
for (i, item) in items.iter().enumerate() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
@@ -481,13 +739,50 @@ fn value_inline(v: &Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_repeat_mode(arg: &str) -> Option<RepeatCliMode> {
|
||||||
|
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<PlayerCliCmd> {
|
fn parse_player_cli_at(args: &[String], pos: usize) -> Option<PlayerCliCmd> {
|
||||||
let verb = args.get(pos + 1)?.as_str();
|
let verb = args.get(pos + 1)?.as_str();
|
||||||
match verb {
|
match verb {
|
||||||
"next" => Some(PlayerCliCmd::Next),
|
"next" => Some(PlayerCliCmd::Next),
|
||||||
"prev" => Some(PlayerCliCmd::Prev),
|
"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),
|
"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" => {
|
"seek" => {
|
||||||
let raw = args.get(pos + 2)?;
|
let raw = args.get(pos + 2)?;
|
||||||
let delta_secs: i32 = raw.parse().ok()?;
|
let delta_secs: i32 = raw.parse().ok()?;
|
||||||
@@ -547,6 +842,35 @@ pub fn parse_cli_command(args: &[String]) -> Option<CliCommand> {
|
|||||||
_ => None,
|
_ => 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),
|
_ => parse_player_cli_at(args, pos).map(CliCommand::Player),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,6 +954,26 @@ pub fn handle_cli_on_primary_instance<R: Runtime>(app: &AppHandle<R>, argv: &[St
|
|||||||
let _ = app.emit("cli:library-set", folder.clone());
|
let _ = app.emit("cli:library-set", folder.clone());
|
||||||
true
|
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,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -678,6 +1022,7 @@ pub fn spawn_deferred_cli_argv_handler<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
let _ = handle.emit("cli:instant-mix", s);
|
let _ = handle.emit("cli:instant-mix", s);
|
||||||
}
|
}
|
||||||
CliCommand::LibraryList => {
|
CliCommand::LibraryList => {
|
||||||
|
let _ = std::fs::remove_file(cli_library_response_path());
|
||||||
let _ = handle.emit("cli:library-list", ());
|
let _ = handle.emit("cli:library-list", ());
|
||||||
let text = read_library_cli_response_blocking(Duration::from_secs(3));
|
let text = read_library_cli_response_blocking(Duration::from_secs(3));
|
||||||
print_library_cli_stdout(&text, json_out);
|
print_library_cli_stdout(&text, json_out);
|
||||||
@@ -685,6 +1030,29 @@ pub fn spawn_deferred_cli_argv_handler<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
CliCommand::LibrarySet(folder) => {
|
CliCommand::LibrarySet(folder) => {
|
||||||
let _ = handle.emit("cli:library-set", folder.clone());
|
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 {
|
if !quiet {
|
||||||
println!("OK: {ok_line} (applied after startup)");
|
println!("OK: {ok_line} (applied after startup)");
|
||||||
@@ -694,7 +1062,7 @@ pub fn spawn_deferred_cli_argv_handler<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
|
|
||||||
pub fn describe_cli_command(cmd: &CliCommand) -> String {
|
pub fn describe_cli_command(cmd: &CliCommand) -> String {
|
||||||
match cmd {
|
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::AudioDeviceList => "audio-device list".into(),
|
||||||
CliCommand::AudioDeviceSet(None) => "audio-device set default".into(),
|
CliCommand::AudioDeviceSet(None) => "audio-device set default".into(),
|
||||||
CliCommand::AudioDeviceSet(Some(s)) => format!("audio-device set {s}"),
|
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::LibraryList => "library list".into(),
|
||||||
CliCommand::LibrarySet(s) if s == "all" => "library set all".into(),
|
CliCommand::LibrarySet(s) if s == "all" => "library set all".into(),
|
||||||
CliCommand::LibrarySet(s) => format!("library set {s}"),
|
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 {
|
match cmd {
|
||||||
PlayerCliCmd::Next => "next track".into(),
|
PlayerCliCmd::Next => "next track".into(),
|
||||||
PlayerCliCmd::Prev => "previous track".into(),
|
PlayerCliCmd::Prev => "previous track".into(),
|
||||||
PlayerCliCmd::Play => "play".into(),
|
PlayerCliCmd::Play => "play".into(),
|
||||||
|
PlayerCliCmd::PlayOpaqueId(id) => format!("play {id}"),
|
||||||
PlayerCliCmd::Pause => "pause".into(),
|
PlayerCliCmd::Pause => "pause".into(),
|
||||||
|
PlayerCliCmd::Stop => "stop".into(),
|
||||||
PlayerCliCmd::Seek { delta_secs } => format!("seek {delta_secs:+} s"),
|
PlayerCliCmd::Seek { delta_secs } => format!("seek {delta_secs:+} s"),
|
||||||
PlayerCliCmd::Volume { percent } => format!("volume {percent}%"),
|
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<R: Runtime>(app: &AppHandle<R>, cmd: PlayerCliCmd) {
|
|||||||
PlayerCliCmd::Play => {
|
PlayerCliCmd::Play => {
|
||||||
let _ = app.emit("media:play", ());
|
let _ = app.emit("media:play", ());
|
||||||
}
|
}
|
||||||
|
PlayerCliCmd::PlayOpaqueId(id) => {
|
||||||
|
let _ = app.emit("cli:play-id", id);
|
||||||
|
}
|
||||||
PlayerCliCmd::Pause => {
|
PlayerCliCmd::Pause => {
|
||||||
let _ = app.emit("media:pause", ());
|
let _ = app.emit("media:pause", ());
|
||||||
}
|
}
|
||||||
|
PlayerCliCmd::Stop => {
|
||||||
|
let _ = app.emit("media:stop", ());
|
||||||
|
}
|
||||||
PlayerCliCmd::Seek { delta_secs } => {
|
PlayerCliCmd::Seek { delta_secs } => {
|
||||||
let _ = app.emit("media:seek-relative", delta_secs);
|
let _ = app.emit("media:seek-relative", delta_secs);
|
||||||
}
|
}
|
||||||
PlayerCliCmd::Volume { percent } => {
|
PlayerCliCmd::Volume { percent } => {
|
||||||
let _ = app.emit("media:set-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<LinuxPl
|
|||||||
Some(CliCommand::LibraryList) => {
|
Some(CliCommand::LibraryList) => {
|
||||||
let _ = std::fs::remove_file(cli_library_response_path());
|
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<LinuxPl
|
|||||||
if !wants_quiet(args) {
|
if !wants_quiet(args) {
|
||||||
println!("OK: library list");
|
println!("OK: library list");
|
||||||
}
|
}
|
||||||
|
} else if let Some(CliCommand::ServerList) = parse_cli_command(args) {
|
||||||
|
let json_out = wants_cli_json_output(args);
|
||||||
|
let text = read_server_list_cli_response_blocking(Duration::from_secs(3));
|
||||||
|
print_server_list_cli_stdout(&text, json_out);
|
||||||
|
if !wants_quiet(args) {
|
||||||
|
println!("OK: server list");
|
||||||
|
}
|
||||||
|
} else if let Some(CliCommand::Search { .. }) = parse_cli_command(args) {
|
||||||
|
let json_out = wants_cli_json_output(args);
|
||||||
|
let text = read_search_cli_response_blocking(Duration::from_secs(12));
|
||||||
|
print_search_cli_stdout(&text, json_out);
|
||||||
|
if !wants_quiet(args) {
|
||||||
|
if let Some(cmd) = parse_cli_command(args) {
|
||||||
|
println!("OK: {}", describe_cli_command(&cmd));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if !wants_quiet(args) {
|
} else if !wants_quiet(args) {
|
||||||
if let Some(cmd) = parse_cli_command(args) {
|
if let Some(cmd) = parse_cli_command(args) {
|
||||||
println!("OK: {}", describe_cli_command(&cmd));
|
println!("OK: {}", describe_cli_command(&cmd));
|
||||||
|
|||||||
+49
-4
@@ -58,6 +58,18 @@ fn cli_publish_library_list(payload: serde_json::Value) -> Result<(), String> {
|
|||||||
crate::cli::write_library_cli_response(&payload)
|
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).
|
/// 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) {
|
||||||
@@ -2109,6 +2121,30 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
|||||||
.build(app)
|
.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<TrayIcon> {
|
||||||
|
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.
|
/// Show (`true`) or fully remove (`false`) the system-tray icon.
|
||||||
///
|
///
|
||||||
/// The command is strictly idempotent:
|
/// The command is strictly idempotent:
|
||||||
@@ -2132,7 +2168,12 @@ fn toggle_tray_icon(
|
|||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return Ok(());
|
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() {
|
} else if let Some(tray) = guard.take() {
|
||||||
// Hide synchronously before dropping so the OS processes the removal
|
// Hide synchronously before dropping so the OS processes the removal
|
||||||
// before any subsequent show=true call can create a new icon.
|
// before any subsequent show=true call can create a new icon.
|
||||||
@@ -2267,11 +2308,13 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── System tray ───────────────────────────────────────────────
|
// ── 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.
|
// 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())?;
|
if let Some(tray) = try_build_tray_icon(app.handle()) {
|
||||||
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||||
@@ -2415,6 +2458,8 @@ pub fn run() {
|
|||||||
exit_app,
|
exit_app,
|
||||||
cli_publish_player_snapshot,
|
cli_publish_player_snapshot,
|
||||||
cli_publish_library_list,
|
cli_publish_library_list,
|
||||||
|
cli_publish_server_list,
|
||||||
|
cli_publish_search_results,
|
||||||
set_window_decorations,
|
set_window_decorations,
|
||||||
no_compositing_mode,
|
no_compositing_mode,
|
||||||
is_tiling_wm_cmd,
|
is_tiling_wm_cmd,
|
||||||
|
|||||||
+192
-1
@@ -58,10 +58,21 @@ 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, 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 { useOfflineStore } from './store/offlineStore';
|
||||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
import { playByOpaqueId } from './utils/playByOpaqueId';
|
||||||
|
import { switchActiveServer } from './utils/switchActiveServer';
|
||||||
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
|
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';
|
||||||
@@ -72,6 +83,9 @@ import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
|||||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
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 }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||||
@@ -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<string>('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<string>('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<string>('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<boolean>('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<number>('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.
|
// 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);
|
||||||
@@ -622,6 +801,7 @@ function TauriEventBridge() {
|
|||||||
['tray:play-pause', () => togglePlay()],
|
['tray:play-pause', () => togglePlay()],
|
||||||
['tray:next', () => next()],
|
['tray:next', () => next()],
|
||||||
['tray:previous', () => previous()],
|
['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-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)); }],
|
['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 auth = useAuthStore.getState();
|
||||||
const sid = auth.activeServerId;
|
const sid = auth.activeServerId;
|
||||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
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 = {
|
const snapshot = {
|
||||||
current_track: s.currentTrack,
|
current_track: s.currentTrack,
|
||||||
current_radio: s.currentRadio,
|
current_radio: s.currentRadio,
|
||||||
@@ -696,6 +883,10 @@ function TauriEventBridge() {
|
|||||||
is_playing: s.isPlaying,
|
is_playing: s.isPlaying,
|
||||||
current_time: s.currentTime,
|
current_time: s.currentTime,
|
||||||
volume: s.volume,
|
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: {
|
music_library: {
|
||||||
active_server_id: sid,
|
active_server_id: sid,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user