mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Merge pull request #187 from cucadmuh/feat/cli-completions-player-controls
Reviewed and merged. Clean architecture, correct D-Bus forwarding design, solid completions scripts. Minor nits noted in review (parse_cli_command called 4x, search fetches all types, 500ms sleep comment) — none blocking.
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,144 @@
|
||||
#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_snapshot_json() {
|
||||
local f
|
||||
if [[ -n $XDG_RUNTIME_DIR ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { print -r -- "$f"; return }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && print -r -- "$f"
|
||||
}
|
||||
|
||||
_psysonic_server_ids() {
|
||||
command -v jq &>/dev/null || return
|
||||
local jf
|
||||
jf="$(_psysonic_snapshot_json)" || return
|
||||
local -a ids
|
||||
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
|
||||
(( $#ids )) && compadd -a ids
|
||||
}
|
||||
|
||||
_psysonic_globals() {
|
||||
compadd -J options -X 'option' -- \
|
||||
--help --version --info --json --quiet --player completions
|
||||
}
|
||||
|
||||
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 stop seek volume shuffle repeat mute unmute star unstar rating reload \
|
||||
audio-device library server search 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
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
compadd list set
|
||||
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
|
||||
_psysonic_server_ids
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
compadd track album artist
|
||||
elif (( n >= 2 )); then
|
||||
_message -e descriptions 'search query'
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && compadd off all one
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && compadd 0 1 2 3 4 5
|
||||
;;
|
||||
seek)
|
||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||
;;
|
||||
volume)
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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.
|
||||
#
|
||||
# compopt is bash-only (programmable completion). Guard so sourcing this file
|
||||
# under zsh or plain sh does not print "command not found: compopt".
|
||||
|
||||
_psysonic_compopt() {
|
||||
command -v compopt &>/dev/null || return 0
|
||||
compopt "$@" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
_psysonic_compreply_from_compgen() {
|
||||
# $1 = compgen -W word list, $2 = current word
|
||||
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_snapshot_json() {
|
||||
local f
|
||||
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && { printf '%s' "$f"; return; }
|
||||
fi
|
||||
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
|
||||
[[ -r $f ]] && printf '%s' "$f"
|
||||
}
|
||||
|
||||
_psysonic_complete() {
|
||||
local cur
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
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 stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search 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[@]})) && _psysonic_compopt -o filenames
|
||||
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[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
mix)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
|
||||
;;
|
||||
server)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'list set' "$cur"
|
||||
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
|
||||
COMPREPLY=()
|
||||
local jf sid
|
||||
jf="$(_psysonic_snapshot_json)"
|
||||
if [[ -n $jf ]] && command -v jq &>/dev/null; then
|
||||
while IFS= read -r sid; do
|
||||
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
|
||||
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
|
||||
fi
|
||||
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
|
||||
fi
|
||||
;;
|
||||
search)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen 'track album artist' "$cur"
|
||||
elif (( n >= 2 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
repeat)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
|
||||
;;
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek|volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
play)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _psysonic_complete psysonic
|
||||
Generated
+1
@@ -3615,6 +3615,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows 0.58.0",
|
||||
"zbus 5.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -52,6 +52,9 @@ symphonia-adapter-libopus = "0.2.7"
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
|
||||
+15
-10
@@ -1473,7 +1473,7 @@ pub struct AudioEngine {
|
||||
pub eq_gains: Arc<[AtomicU32; 10]>,
|
||||
pub eq_enabled: Arc<AtomicBool>,
|
||||
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_secs: Arc<AtomicU32>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||
@@ -1482,7 +1482,7 @@ pub struct AudioEngine {
|
||||
pub gapless_enabled: Arc<AtomicBool>,
|
||||
/// Info about the next-up chained track (gapless mode).
|
||||
/// 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.
|
||||
/// Progress task reads this for drift-free position tracking.
|
||||
pub samples_played: Arc<AtomicU64>,
|
||||
@@ -1495,7 +1495,7 @@ pub struct AudioEngine {
|
||||
pub gapless_switch_at: Arc<AtomicU64>,
|
||||
/// Active radio session state. None for regular (non-radio) tracks.
|
||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||
pub radio_state: Mutex<Option<RadioLiveState>>,
|
||||
pub(crate) radio_state: Mutex<Option<RadioLiveState>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -3026,6 +3026,17 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<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.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
@@ -3034,13 +3045,7 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *state.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
audio_list_devices_for_engine(&state)
|
||||
}
|
||||
|
||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+91
-9
@@ -2,6 +2,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
pub mod cli;
|
||||
mod discord;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod taskbar_win;
|
||||
@@ -45,6 +46,30 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Writes `psysonic-cli-snapshot.json` for `psysonic --info` (debounced from the frontend).
|
||||
#[tauri::command]
|
||||
fn cli_publish_player_snapshot(snapshot: serde_json::Value) -> Result<(), String> {
|
||||
crate::cli::write_cli_snapshot(&snapshot)
|
||||
}
|
||||
|
||||
/// Writes `psysonic-cli-library.json` for `psysonic --player library list`.
|
||||
#[tauri::command]
|
||||
fn cli_publish_library_list(payload: serde_json::Value) -> Result<(), String> {
|
||||
crate::cli::write_library_cli_response(&payload)
|
||||
}
|
||||
|
||||
/// Writes `psysonic-cli-servers.json` for `psysonic --player server list`.
|
||||
#[tauri::command]
|
||||
fn cli_publish_server_list(payload: serde_json::Value) -> Result<(), String> {
|
||||
crate::cli::write_server_list_cli_response(&payload)
|
||||
}
|
||||
|
||||
/// Writes `psysonic-cli-search.json` for `psysonic --player search …`.
|
||||
#[tauri::command]
|
||||
fn cli_publish_search_results(payload: serde_json::Value) -> Result<(), String> {
|
||||
crate::cli::write_search_cli_response(&payload)
|
||||
}
|
||||
|
||||
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||
#[tauri::command]
|
||||
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
@@ -2096,6 +2121,30 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
.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.
|
||||
///
|
||||
/// The command is strictly idempotent:
|
||||
@@ -2119,7 +2168,12 @@ fn toggle_tray_icon(
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
*guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?);
|
||||
let Some(tray) = try_build_tray_icon(&app) else {
|
||||
return Err(
|
||||
"Tray icon could not be created (missing system libraries on Linux).".into(),
|
||||
);
|
||||
};
|
||||
*guard = Some(tray);
|
||||
} else if let Some(tray) = guard.take() {
|
||||
// Hide synchronously before dropping so the OS processes the removal
|
||||
// before any subsequent show=true call can create a new icon.
|
||||
@@ -2199,6 +2253,23 @@ fn is_tiling_wm_cmd() -> bool {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
tauri::Builder::default()
|
||||
@@ -2214,11 +2285,13 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
let window = app.get_webview_window("main").expect("no main window");
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
if !crate::cli::handle_cli_on_primary_instance(app, &argv) {
|
||||
let window = app.get_webview_window("main").expect("no main window");
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
@@ -2235,11 +2308,13 @@ pub fn run() {
|
||||
}
|
||||
|
||||
// ── System tray ───────────────────────────────────────────────
|
||||
// Always build on startup; the frontend calls toggle_tray_icon(false)
|
||||
// Always build on startup when possible; the frontend calls toggle_tray_icon(false)
|
||||
// immediately after load if the user has disabled the tray icon.
|
||||
// May be skipped if Ayatana/AppIndicator libraries are missing (no panic).
|
||||
{
|
||||
let tray = build_tray_icon(app.handle())?;
|
||||
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
||||
if let Some(tray) = try_build_tray_icon(app.handle()) {
|
||||
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
||||
}
|
||||
}
|
||||
|
||||
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
||||
@@ -2350,6 +2425,9 @@ pub fn run() {
|
||||
audio::start_device_watcher(&engine, app.handle().clone());
|
||||
}
|
||||
|
||||
// Cold start with `--player …`: defer emit so the webview can register listeners.
|
||||
crate::cli::spawn_deferred_cli_argv_handler(app.handle());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -2378,6 +2456,10 @@ pub fn run() {
|
||||
greet,
|
||||
calculate_sync_payload,
|
||||
exit_app,
|
||||
cli_publish_player_snapshot,
|
||||
cli_publish_library_list,
|
||||
cli_publish_server_list,
|
||||
cli_publish_search_results,
|
||||
set_window_decorations,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
|
||||
@@ -13,5 +13,24 @@ fn main() {
|
||||
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();
|
||||
}
|
||||
|
||||
+337
-2
@@ -58,10 +58,22 @@ import { IS_LINUX } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import {
|
||||
getMusicFolders,
|
||||
getSimilarSongs,
|
||||
getSong,
|
||||
probeEntityRatingSupport,
|
||||
search as subsonicSearch,
|
||||
setRating,
|
||||
star,
|
||||
unstar,
|
||||
} from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import i18n from './i18n';
|
||||
import { playByOpaqueId } from './utils/playByOpaqueId';
|
||||
import { switchActiveServer } from './utils/switchActiveServer';
|
||||
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
@@ -71,6 +83,9 @@ import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
|
||||
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
|
||||
let cliPremuteVolume: number | null = null;
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||
@@ -453,6 +468,261 @@ function TauriEventBridge() {
|
||||
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?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
// Runs once on mount (initial sync) and again whenever the setting changes.
|
||||
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
|
||||
@@ -524,11 +794,14 @@ function TauriEventBridge() {
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }],
|
||||
['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:stop', () => usePlayerStore.getState().stop()],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
@@ -559,6 +832,15 @@ function TauriEventBridge() {
|
||||
if (cancelled) { u(); return; }
|
||||
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).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
@@ -577,6 +859,59 @@ function TauriEventBridge() {
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
|
||||
useEffect(() => {
|
||||
let tid: ReturnType<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 ct = s.currentTrack;
|
||||
const currentTrackUserRating =
|
||||
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
|
||||
const currentTrackStarred =
|
||||
ct != null
|
||||
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
|
||||
: null;
|
||||
const snapshot = {
|
||||
current_track: s.currentTrack,
|
||||
current_radio: s.currentRadio,
|
||||
queue: s.queue,
|
||||
queue_index: s.queueIndex,
|
||||
queue_length: s.queue.length,
|
||||
is_playing: s.isPlaying,
|
||||
current_time: s.currentTime,
|
||||
volume: s.volume,
|
||||
repeat_mode: s.repeatMode,
|
||||
current_track_user_rating: currentTrackUserRating,
|
||||
current_track_starred: currentTrackStarred,
|
||||
servers: auth.servers.map(({ id, name }) => ({ id, name })),
|
||||
music_library: {
|
||||
active_server_id: sid,
|
||||
selected,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore, type ServerProfile } from '../store/authStore';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -11,28 +16,141 @@ interface Props {
|
||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [switchingId, setSwitchingId] = useState<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 tooltip =
|
||||
status === 'connected'
|
||||
const tooltip = multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server">{serverName}</span>
|
||||
<div className="connection-indicator-host" ref={hostRef}>
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onTriggerClick}
|
||||
data-tooltip={tooltip}
|
||||
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-meta">
|
||||
<span className="connection-type">{label}</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>
|
||||
{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 state = usePlayerStore.getState();
|
||||
if (state.currentTrack?.id === song.id) {
|
||||
if (!state.isPlaying) state.resume();
|
||||
} else {
|
||||
playTrack(song, [song]);
|
||||
}
|
||||
usePlayerStore.getState().reseedQueueForInstantMix(song);
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
try {
|
||||
const similar = await getSimilarSongs(song.id, 50);
|
||||
|
||||
@@ -101,6 +101,7 @@ export const deTranslation = {
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
||||
cliMixNeedsTrack: 'Es läuft nichts — starte zuerst die Wiedergabe und führe den Mix-Befehl erneut aus.',
|
||||
lfmLove: 'Auf Last.fm liken',
|
||||
lfmUnlove: 'Last.fm-Like entfernen',
|
||||
favorite: 'Favorisieren',
|
||||
@@ -355,6 +356,10 @@ export const deTranslation = {
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
serverSettings: 'Server-Einstellungen',
|
||||
switchServerTitle: 'Server wechseln',
|
||||
switchServerHint: 'Klicken, um einen anderen gespeicherten Server zu wählen.',
|
||||
manageServers: 'Server verwalten…',
|
||||
switchFailed: 'Wechsel fehlgeschlagen — Server nicht erreichbar.',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
},
|
||||
|
||||
@@ -102,6 +102,7 @@ export const enTranslation = {
|
||||
startRadio: 'Start Radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
||||
cliMixNeedsTrack: 'Nothing is playing — start playback first, then run the mix command again.',
|
||||
lfmLove: 'Love on Last.fm',
|
||||
lfmUnlove: 'Unlove on Last.fm',
|
||||
favorite: 'Favorite',
|
||||
@@ -356,6 +357,10 @@ export const enTranslation = {
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
serverSettings: 'Server Settings',
|
||||
switchServerTitle: 'Switch server',
|
||||
switchServerHint: 'Click to choose another saved server.',
|
||||
manageServers: 'Manage servers…',
|
||||
switchFailed: 'Could not switch — server unreachable.',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
},
|
||||
|
||||
@@ -102,6 +102,7 @@ export const esTranslation = {
|
||||
startRadio: 'Iniciar Radio',
|
||||
instantMix: 'Mezcla Instantánea',
|
||||
instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.',
|
||||
cliMixNeedsTrack: 'No hay nada en reproducción — inicia la reproducción y vuelve a ejecutar el comando de mezcla.',
|
||||
lfmLove: 'Favorito en Last.fm',
|
||||
lfmUnlove: 'Quitar favorito de Last.fm',
|
||||
favorite: 'Favorito',
|
||||
@@ -357,6 +358,10 @@ export const esTranslation = {
|
||||
offlineFilterArtists: 'Discografías',
|
||||
retry: 'Reintentar',
|
||||
serverSettings: 'Configuración del servidor',
|
||||
switchServerTitle: 'Cambiar servidor',
|
||||
switchServerHint: 'Clic para elegir otro servidor guardado.',
|
||||
manageServers: 'Gestionar servidores…',
|
||||
switchFailed: 'No se pudo cambiar — servidor inalcanzable.',
|
||||
lastfmConnected: 'Last.fm conectado como @{{user}}',
|
||||
lastfmSessionInvalid: 'Sesión inválida — click para reconectar',
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ export const frTranslation = {
|
||||
startRadio: 'Démarrer la radio',
|
||||
instantMix: 'Mix instantané',
|
||||
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
||||
cliMixNeedsTrack: 'Aucune lecture en cours — lancez d’abord la lecture, puis relancez la commande de mix.',
|
||||
lfmLove: 'Aimer sur Last.fm',
|
||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||
favorite: 'Favori',
|
||||
@@ -355,6 +356,10 @@ export const frTranslation = {
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
serverSettings: 'Paramètres serveur',
|
||||
switchServerTitle: 'Changer de serveur',
|
||||
switchServerHint: 'Cliquez pour choisir un autre serveur enregistré.',
|
||||
manageServers: 'Gérer les serveurs…',
|
||||
switchFailed: 'Impossible de changer — serveur injoignable.',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ export const nbTranslation = {
|
||||
startRadio: 'Start radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
||||
cliMixNeedsTrack: 'Ingenting spilles — start avspilling først, og kjør mix-kommandoen på nytt.',
|
||||
lfmLove: 'Lik på Last.fm',
|
||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||
favorite: 'Favoritt',
|
||||
@@ -355,6 +356,10 @@ export const nbTranslation = {
|
||||
offlineFilterArtists: 'Diskografier',
|
||||
retry: 'Prøv igjen',
|
||||
serverSettings: 'Serverinnstillinger',
|
||||
switchServerTitle: 'Bytt server',
|
||||
switchServerHint: 'Klikk for å velge en annen lagret server.',
|
||||
manageServers: 'Administrer servere…',
|
||||
switchFailed: 'Klarte ikke å bytte — serveren er utilgjengelig.',
|
||||
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
|
||||
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ export const nlTranslation = {
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
||||
cliMixNeedsTrack: 'Er speelt niets — start eerst afspelen en voer het mix-commando opnieuw uit.',
|
||||
lfmLove: 'Liken op Last.fm',
|
||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||
favorite: 'Favoriet',
|
||||
@@ -354,6 +355,10 @@ export const nlTranslation = {
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
serverSettings: 'Serverinstellingen',
|
||||
switchServerTitle: 'Server wisselen',
|
||||
switchServerHint: 'Klik om een andere opgeslagen server te kiezen.',
|
||||
manageServers: 'Servers beheren…',
|
||||
switchFailed: 'Wisselen mislukt — server niet bereikbaar.',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
},
|
||||
|
||||
@@ -102,6 +102,7 @@ export const ruTranslation = {
|
||||
startRadio: 'Радио по похожим',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||
cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
@@ -369,6 +370,10 @@ export const ruTranslation = {
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
serverSettings: 'Настройки сервера',
|
||||
switchServerTitle: 'Сменить сервер',
|
||||
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
||||
manageServers: 'Управление серверами…',
|
||||
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
||||
lastfmConnected: 'Last.fm: @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ export const zhTranslation = {
|
||||
startRadio: '开始电台',
|
||||
instantMix: '即时混音',
|
||||
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||
cliMixNeedsTrack: '当前没有正在播放的内容 — 请先开始播放,然后再执行混音命令。',
|
||||
lfmLove: '在 Last.fm 上标记喜欢',
|
||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||
favorite: '收藏',
|
||||
@@ -350,6 +351,10 @@ export const zhTranslation = {
|
||||
offlineAlbumCount_plural: '{{n}} 张专辑',
|
||||
retry: '重试',
|
||||
serverSettings: '服务器设置',
|
||||
switchServerTitle: '切换服务器',
|
||||
switchServerHint: '点击选择其他已保存的服务器。',
|
||||
manageServers: '管理服务器…',
|
||||
switchFailed: '无法切换 — 无法连接服务器。',
|
||||
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
|
||||
lastfmSessionInvalid: '会话无效 — 点击重新连接',
|
||||
},
|
||||
|
||||
+6
-17
@@ -33,6 +33,7 @@ import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../components/Sidebar';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import Equalizer from '../components/Equalizer';
|
||||
@@ -572,23 +573,11 @@ export default function Settings() {
|
||||
|
||||
const switchToServer = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
try {
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ping.ok) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
auth.setSubsonicServerIdentity(server.id, identity);
|
||||
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
navigate('/');
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
} catch {
|
||||
const ok = await switchActiveServer(server);
|
||||
if (ok) {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'ok' }));
|
||||
navigate('/');
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
@@ -131,6 +131,8 @@ interface PlayerState {
|
||||
|
||||
playRadio: (station: InternetRadioStation) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
/** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */
|
||||
reseedQueueForInstantMix: (track: Track) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
@@ -942,6 +944,22 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
||||
},
|
||||
|
||||
reseedQueueForInstantMix: (track) => {
|
||||
const s = get();
|
||||
if (s.currentTrack?.id !== track.id) {
|
||||
get().playTrack(track, [track]);
|
||||
return;
|
||||
}
|
||||
const wasPlaying = s.isPlaying;
|
||||
set({
|
||||
queue: [track],
|
||||
queueIndex: 0,
|
||||
currentTrack: track,
|
||||
});
|
||||
syncQueueToServer([track], track, s.currentTime);
|
||||
if (!wasPlaying) get().resume();
|
||||
},
|
||||
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
if (get().currentRadio) {
|
||||
|
||||
@@ -4090,6 +4090,24 @@ html.no-compositing .fs-seekbar-played {
|
||||
}
|
||||
|
||||
/* ─ Connection Indicator ─ */
|
||||
.connection-indicator-host {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-indicator-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, 85vw);
|
||||
}
|
||||
|
||||
.connection-indicator-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
transition: transform var(--transition-fast, 0.15s ease);
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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