mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
184e87a469
* fix(audio): release idle output stream after 60s (#1071) Lazy-open CPAL on first playback and close the device handle after one minute without active audio so Windows can sleep; emit output-released for cold resume and skip post-wake reopen when idle. * docs: CHANGELOG and credits for idle audio stream fix (PR #1073) * fix(audio): satisfy clippy if-same-then-else in idle watcher * fix(audio): silence rodio DeviceSink drop unless logging is debug Gate log_on_drop(false) on runtime should_log_debug() so normal/off logging modes avoid stderr noise from intentional idle stream release. * feat(audio): cold-start paused restore and silent engine prepare After getPlayQueue on startup, apply saved seek position to the UI, prefetch the current track to hot cache, and load the engine paused via new audio_play startPaused so playback does not audibly start before pause. Shared engineLoadTrackAtPosition with queue-undo restore. * fix(audio): satisfy clippy too_many_arguments on stream arm helper Bundle spawn_legacy_stream_start_when_armed parameters into LegacyStreamStartWhenArmed so workspace clippy passes. * fix(audio): release output stream immediately on stop (#1071) Stop and natural queue end call audio_stop; close the CPAL device right away instead of waiting for the 60s idle timer. Pause keeps the grace period for warm resume. * fix(audio): keep waveform mounted after stop (#1071) Stop preserves currentTrack, so its cached analysis waveform stays valid. Stop no longer nulls waveformBins for the still-shown track and re-hydrates them from the analysis DB, instead of dropping to flat placeholder bars. * test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071) - Add unit tests for the idle-keepalive decision: empty/playing/paused main sink, preview and fading-out sinks, and radio playing/paused. Players are built device-less via rodio's Player::new + a Zero source, so empty()/state are exercised without an audio device. - Make audio_play's `start_paused` an Option<bool> defaulting to false, so the new field is strictly additive (omitting startPaused no longer fails serde). - Drop the unused `_engine` parameter from start_stream_idle_watcher; it resolves the engine from the AppHandle each poll. * refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071) Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper (LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of play_input.rs into a focused sink_swap.rs, so source selection and source building stay separate from sink lifecycle. play_input.rs drops from 953 to 799 lines. No behavior change.
892 lines
45 KiB
Rust
892 lines
45 KiB
Rust
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
pub mod cli;
|
|
mod cover_cache;
|
|
mod library_analysis_backfill;
|
|
mod lib_commands;
|
|
mod theme_import;
|
|
pub mod theme_animation;
|
|
|
|
pub use psysonic_integration::discord;
|
|
|
|
pub use psysonic_core::logging;
|
|
pub use psysonic_core::{app_eprintln, app_deprintln};
|
|
pub use psysonic_core::user_agent::{
|
|
default_subsonic_wire_user_agent, runtime_subsonic_wire_user_agent, subsonic_wire_user_agent,
|
|
};
|
|
pub use psysonic_analysis::{analysis_cache, analysis_runtime};
|
|
pub use psysonic_audio as audio;
|
|
pub use psysonic_syncfs::{sync_cancel_flags, DownloadSemaphore};
|
|
#[cfg(target_os = "windows")]
|
|
mod taskbar_win;
|
|
mod tray_runtime;
|
|
|
|
pub(crate) use tray_runtime::*;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use tauri::{Emitter, Manager};
|
|
use lib_commands::*;
|
|
|
|
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
|
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
|
type ShortcutMap = Mutex<HashMap<String, String>>;
|
|
|
|
/// Maximum number of offline track downloads that can run concurrently.
|
|
/// The frontend queues more tasks than this; Rust is the real throttle.
|
|
const MAX_DL_CONCURRENCY: usize = 4;
|
|
|
|
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
|
|
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
|
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
|
|
|
|
/// Release builds only: focus or CLI-hand off when a second instance is launched.
|
|
#[cfg(not(debug_assertions))]
|
|
fn on_second_instance<R: tauri::Runtime>(
|
|
app: &tauri::AppHandle<R>,
|
|
argv: Vec<String>,
|
|
_cwd: String,
|
|
) {
|
|
if !crate::cli::handle_cli_on_primary_instance(app, &argv) {
|
|
let window = app.get_webview_window("main").expect("no main window");
|
|
// The window may have been hidden via the close-to-tray path,
|
|
// which injects PAUSE_RENDERING_JS (sets `__psyHidden=true`,
|
|
// pauses CSS animations). Tray-icon restore mirrors this with
|
|
// RESUME_RENDERING_JS — second-launch restore must do the same,
|
|
// otherwise the webview comes back with rendering still paused
|
|
// and navigation looks blank (issue #497).
|
|
let _ = window.eval(RESUME_RENDERING_JS);
|
|
let _ = window.show();
|
|
let _ = window.unminimize();
|
|
let _ = window.set_focus();
|
|
}
|
|
}
|
|
|
|
pub fn run() {
|
|
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let argv: Vec<String> = std::env::args().collect();
|
|
if crate::cli::parse_cli_command(&argv).is_some() {
|
|
match crate::cli::linux_try_forward_player_cli_secondary(&argv) {
|
|
Ok(crate::cli::LinuxPlayerForwardResult::Forwarded) => std::process::exit(0),
|
|
Ok(crate::cli::LinuxPlayerForwardResult::ContinueStartup) => {}
|
|
Err(msg) => {
|
|
crate::app_eprintln!("NOT OK: {msg}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let (audio_engine, _audio_thread) = audio::create_engine();
|
|
|
|
let builder = tauri::Builder::default()
|
|
.manage(audio_engine)
|
|
.manage(ShortcutMap::default())
|
|
.manage(discord::DiscordState::new())
|
|
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
|
.manage(TrayState::default())
|
|
.manage(TrayTooltip::default())
|
|
.manage(TrayPlaybackState::default())
|
|
.manage(TrayMenuItemsState::default())
|
|
.manage(TrayMenuLabelsState::default())
|
|
.plugin(tauri_plugin_process::init())
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(
|
|
tauri_plugin_window_state::Builder::default()
|
|
.with_state_flags(
|
|
tauri_plugin_window_state::StateFlags::all()
|
|
& !tauri_plugin_window_state::StateFlags::VISIBLE,
|
|
)
|
|
.with_denylist(&["mini"])
|
|
.build()
|
|
)
|
|
.plugin(tauri_plugin_shell::init())
|
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
|
.plugin(tauri_plugin_store::Builder::default().build())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_fs::init());
|
|
|
|
#[cfg(not(debug_assertions))]
|
|
let builder = builder.plugin(tauri_plugin_single_instance::init(on_second_instance));
|
|
|
|
builder
|
|
.setup(|app| {
|
|
#[cfg(debug_assertions)]
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.set_title("Psysonic (Dev)");
|
|
}
|
|
|
|
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
|
|
// Poll a local theme.css and push it into the running app on save,
|
|
// so theme authors get a live loop without re-importing a zip. The
|
|
// frontend (dev only) installs it under the id in its
|
|
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
let args: Vec<String> = std::env::args().collect();
|
|
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
|
|
match args.get(i + 1).cloned() {
|
|
Some(path) => {
|
|
eprintln!("[theme-watch] watching {path}");
|
|
let handle = app.handle().clone();
|
|
std::thread::spawn(move || {
|
|
let p = std::path::PathBuf::from(&path);
|
|
let mut last_css = String::new();
|
|
loop {
|
|
if let Ok(css) = std::fs::read_to_string(&p) {
|
|
if css != last_css {
|
|
last_css = css.clone();
|
|
let _ = handle.emit("theme-watch:css", css);
|
|
}
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
|
}
|
|
});
|
|
}
|
|
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Analysis cache (SQLite) ───────────────────────────────────
|
|
{
|
|
let cache = analysis_cache::AnalysisCache::init(app.handle())
|
|
.map_err(|e| format!("analysis cache init failed: {e}"))?;
|
|
app.manage(cache);
|
|
}
|
|
|
|
cover_cache::init_cover_cache(app.handle())
|
|
.map_err(|e| format!("cover cache init failed: {e}"))?;
|
|
|
|
library_analysis_backfill::init_library_analysis_backfill(app.handle())
|
|
.map_err(|e| format!("library analysis backfill init failed: {e}"))?;
|
|
|
|
// ── Library track store (psysonic-library, PR-5a + PR-5b) ─────
|
|
// PR-5a brought up the read-only Tauri surface + LibraryRuntime.
|
|
// PR-5b adds the mutating commands, sync session map, current-job
|
|
// tracker, and the 30-second background scheduler tick task below
|
|
// — which sweeps every bound session through
|
|
// `BackgroundScheduler::tick` while honouring the runtime's
|
|
// `scheduler_cancel` flag.
|
|
{
|
|
let store = psysonic_library::store::LibraryStore::init(app.handle())
|
|
.map_err(|e| format!("library store init failed: {e}"))?;
|
|
let runtime = psysonic_library::LibraryRuntime::new(std::sync::Arc::new(store));
|
|
app.manage(runtime);
|
|
|
|
let app_for_sched = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
use tokio::time::MissedTickBehavior;
|
|
|
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
|
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
|
loop {
|
|
interval.tick().await;
|
|
let Some(state) = app_for_sched
|
|
.try_state::<psysonic_library::LibraryRuntime>()
|
|
else {
|
|
break;
|
|
};
|
|
if state.scheduler_cancel.load(Ordering::SeqCst) {
|
|
break;
|
|
}
|
|
let sessions = state.snapshot_sessions();
|
|
if sessions.is_empty() {
|
|
continue;
|
|
}
|
|
let hint = state.current_playback_hint();
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
|
.unwrap_or(0);
|
|
for session in sessions {
|
|
let scope = session.library_scope.clone().unwrap_or_default();
|
|
let flags_bits = psysonic_library::repos::SyncStateRepository::new(
|
|
&state.store,
|
|
)
|
|
.get_capability_flags(&session.server_id, &scope)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(0);
|
|
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
|
|
flags_bits,
|
|
);
|
|
let subsonic = psysonic_integration::subsonic::SubsonicClient::new(
|
|
session.base_url.clone(),
|
|
session.username.clone(),
|
|
session.password.clone(),
|
|
);
|
|
let mut sched =
|
|
psysonic_library::sync::scheduler::BackgroundScheduler::new(
|
|
&state.store,
|
|
&subsonic,
|
|
session.server_id.clone(),
|
|
scope.clone(),
|
|
flags,
|
|
)
|
|
.with_playback_hint(hint);
|
|
if let Some(tok) = session.navidrome_token.clone() {
|
|
sched = sched.with_navidrome_credentials(
|
|
psysonic_library::sync::capability::NavidromeProbeCredentials {
|
|
server_url: session.base_url.clone(),
|
|
bearer_token: tok,
|
|
},
|
|
);
|
|
}
|
|
let foreground_job = state
|
|
.current_job()
|
|
.is_some_and(|j| j.server_id == session.server_id);
|
|
if foreground_job {
|
|
sched = sched.with_foreground_sync_job_active(true);
|
|
}
|
|
let _ = sched.tick(now_ms).await;
|
|
// Background ticks stay silent in PR-5b — Tauri
|
|
// emit for the scheduler path lands when the
|
|
// Settings panel needs it (PR-5c). Manual
|
|
// `library_sync_start` already emits via its
|
|
// own orchestrator.
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
audio::cleanup_orphan_stream_spill_dir(app.handle());
|
|
|
|
// ── Playback-query port (analysis → audio back-edge) ──────────
|
|
// Two closures, each capturing an AppHandle, so analysis_runtime
|
|
// can ask AudioEngine playback questions without depending on the
|
|
// audio crate.
|
|
{
|
|
let app_is_playing = app.handle().clone();
|
|
let app_defer = app.handle().clone();
|
|
let handle = psysonic_core::ports::PlaybackQueryHandle::new(
|
|
move |track_id| {
|
|
app_is_playing
|
|
.try_state::<crate::audio::AudioEngine>()
|
|
.is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id))
|
|
},
|
|
move |track_id| {
|
|
app_defer
|
|
.try_state::<crate::audio::AudioEngine>()
|
|
.is_some_and(|e| crate::audio::ranged_loudness_backfill_should_defer(&e, track_id))
|
|
},
|
|
);
|
|
app.manage(handle);
|
|
}
|
|
|
|
app.manage(psysonic_analysis::analysis_runtime::PlaybackPriorityHints::default());
|
|
|
|
// ── Content-hash sink (analysis → library E2 back-edge) ───────
|
|
// After a seed the analysis pipeline records the playback-derived
|
|
// md5_16kb as `track.content_hash` so id-remap can rebind a track
|
|
// when the server reassigns ids. Decoupled from psysonic-library
|
|
// via a psysonic-core port; a no-op when the library has no row for
|
|
// the (server_id, track_id) — i.e. the index is off for that server.
|
|
{
|
|
let app_for_hash = app.handle().clone();
|
|
let sink = psysonic_core::ports::ContentHashSink::new(
|
|
move |server_id: &str, track_id: &str, md5: &str| {
|
|
if let Some(runtime) =
|
|
app_for_hash.try_state::<psysonic_library::LibraryRuntime>()
|
|
{
|
|
let _ = psysonic_library::commands::patch_content_hash(
|
|
&runtime, server_id, track_id, md5,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
app.manage(sink);
|
|
}
|
|
|
|
// ── Analysis-readiness query (library → analysis E3 back-edge) ──
|
|
// `library_get_track` enrichment asks whether waveform/loudness are
|
|
// cached for (server_id, track_id, content_hash). Read-only probe:
|
|
// exact key then legacy '' fallback, no re-tag. Decoupled from
|
|
// psysonic-analysis via a psysonic-core port.
|
|
{
|
|
let app_for_readiness = app.handle().clone();
|
|
let query = psysonic_core::ports::AnalysisReadinessQuery::new(
|
|
move |server_id: &str, track_id: &str, md5: &str| {
|
|
let Some(cache) = app_for_readiness
|
|
.try_state::<analysis_cache::AnalysisCache>()
|
|
else {
|
|
return (false, false);
|
|
};
|
|
let probe = |sid: &str| {
|
|
let key = analysis_cache::TrackKey {
|
|
server_id: sid.to_string(),
|
|
track_id: track_id.to_string(),
|
|
md5_16kb: md5.to_string(),
|
|
};
|
|
let wf = cache.get_waveform(&key).ok().flatten().is_some();
|
|
let ld = cache.loudness_row_exists_for_key(&key).unwrap_or(false);
|
|
(wf, ld)
|
|
};
|
|
let (wf, ld) = probe(server_id);
|
|
// Legacy '' fallback for rows analysed before E1 wiring.
|
|
let wf = wf || (!server_id.is_empty() && probe("").0);
|
|
let ld = ld || (!server_id.is_empty() && probe("").1);
|
|
(wf, ld)
|
|
},
|
|
);
|
|
app.manage(query);
|
|
}
|
|
|
|
// ── Analysis needs-work probe (library → analysis batch scan) ──
|
|
{
|
|
use psysonic_core::ports::TrackAnalysisNeedsWorkQuery;
|
|
let app_for_needs_work = app.handle().clone();
|
|
let needs_work = TrackAnalysisNeedsWorkQuery::new(
|
|
move |server_id: &str, track_id: &str| {
|
|
psysonic_analysis::analysis_runtime::track_analysis_needs_work(
|
|
&app_for_needs_work,
|
|
server_id,
|
|
track_id,
|
|
)
|
|
},
|
|
);
|
|
app.manage(needs_work);
|
|
}
|
|
|
|
// ── Track enrichment port (analysis → library facts) ───────────
|
|
{
|
|
use psysonic_core::track_enrichment::{TrackEnrichmentPlan, TrackEnrichmentPort};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn enrichment_now_unix_ms() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
let app_for_enrichment_plan = app.handle().clone();
|
|
let app_for_enrichment_store = app.handle().clone();
|
|
let port = TrackEnrichmentPort::new(
|
|
move |server_id: &str, track_id: &str, content_hash: &str| {
|
|
let Some(runtime) =
|
|
app_for_enrichment_plan.try_state::<psysonic_library::LibraryRuntime>()
|
|
else {
|
|
return TrackEnrichmentPlan::default();
|
|
};
|
|
match psysonic_library::enrichment::plan_track_enrichment(
|
|
&runtime.store,
|
|
server_id,
|
|
track_id,
|
|
content_hash,
|
|
enrichment_now_unix_ms(),
|
|
) {
|
|
Ok(plan) => plan,
|
|
Err(e) => {
|
|
eprintln!(
|
|
"[enrichment] plan failed server_id={server_id} track_id={track_id}: {e}"
|
|
);
|
|
TrackEnrichmentPlan {
|
|
need_bpm: true,
|
|
need_valence: true,
|
|
need_arousal: true,
|
|
need_moods: true,
|
|
}
|
|
}
|
|
}
|
|
},
|
|
move |server_id: &str,
|
|
track_id: &str,
|
|
content_hash: &str,
|
|
facts: &psysonic_core::track_enrichment::TrackEnrichmentFacts| {
|
|
let Some(runtime) =
|
|
app_for_enrichment_store.try_state::<psysonic_library::LibraryRuntime>()
|
|
else {
|
|
return Err("library runtime unavailable".into());
|
|
};
|
|
psysonic_library::enrichment::store_track_enrichment_facts(
|
|
&runtime.store,
|
|
server_id,
|
|
track_id,
|
|
content_hash,
|
|
facts,
|
|
enrichment_now_unix_ms(),
|
|
)
|
|
},
|
|
);
|
|
app.manage(port);
|
|
}
|
|
|
|
// Periodic analysis queue sizes (debug logging mode only).
|
|
tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop());
|
|
|
|
// ── Custom title bar on Linux ─────────────────────────────────
|
|
// Remove OS window decorations on all Linux so the React TitleBar
|
|
// can take over. The frontend checks is_tiling_wm() to decide
|
|
// whether to actually render the TitleBar (hidden on tiling WMs).
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use tauri::Manager;
|
|
let handle = app.handle().clone();
|
|
sync_wayland_text_profile_cache_from_disk(&handle);
|
|
if let Some(win) = app.get_webview_window("main") {
|
|
let _ = win.set_decorations(false);
|
|
let _ = linux_webkit_apply_wayland_gpu_font_tuning(&win);
|
|
let _ = linux_webkit_reapply_cached_wayland_text_render_profile(&win);
|
|
// Suppress WebKit's own MPRIS player so radio (HTML <audio>)
|
|
// doesn't duplicate the souvlaki one (issue #1048).
|
|
let _ = linux_webkit_disable_media_session(&win);
|
|
}
|
|
}
|
|
|
|
// ── System tray ───────────────────────────────────────────────
|
|
// 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).
|
|
{
|
|
if let Some(tray) = try_build_tray_icon(app.handle()) {
|
|
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
|
}
|
|
}
|
|
|
|
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
|
// Release only: debug builds share the D-Bus name / SMTC slot with prod.
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
|
|
|
// Collect pre-conditions and the platform-specific HWND.
|
|
// Returns None early (with a log) on any unrecoverable condition
|
|
// so app.manage() always executes exactly once at the bottom.
|
|
let maybe_controls: Option<MediaControls> = (|| {
|
|
// Linux: requires a live D-Bus session.
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
|
|
.map(|v| !v.is_empty())
|
|
.unwrap_or(false);
|
|
if !dbus_ok {
|
|
crate::app_eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
|
|
return None;
|
|
}
|
|
}
|
|
|
|
// Windows: souvlaki SMTC must hook into the existing Win32
|
|
// message loop rather than spinning up its own. Pass the
|
|
// main window's HWND so it can do so. If we can't get one,
|
|
// skip init (no crash, just no media overlay).
|
|
#[cfg(target_os = "windows")]
|
|
let hwnd = {
|
|
use tauri::Manager;
|
|
let h = app.get_webview_window("main")
|
|
.and_then(|w| w.hwnd().ok())
|
|
.map(|h| h.0);
|
|
if h.is_none() {
|
|
crate::app_eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled");
|
|
return None;
|
|
}
|
|
h
|
|
};
|
|
#[cfg(not(target_os = "windows"))]
|
|
let hwnd: Option<*mut std::ffi::c_void> = None;
|
|
|
|
let config = PlatformConfig {
|
|
dbus_name: "psysonic",
|
|
display_name: "Psysonic",
|
|
hwnd,
|
|
};
|
|
|
|
match MediaControls::new(config) {
|
|
Ok(mut controls) => {
|
|
let app_handle = app.handle().clone();
|
|
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
|
match event {
|
|
MediaControlEvent::Toggle
|
|
| MediaControlEvent::Play
|
|
| MediaControlEvent::Pause => {
|
|
let _ = app_handle.emit("media:play-pause", ());
|
|
}
|
|
MediaControlEvent::Next => {
|
|
let _ = app_handle.emit("media:next", ());
|
|
}
|
|
MediaControlEvent::Previous => {
|
|
let _ = app_handle.emit("media:prev", ());
|
|
}
|
|
MediaControlEvent::Seek(direction) => {
|
|
use souvlaki::SeekDirection;
|
|
let delta: f64 = match direction {
|
|
SeekDirection::Forward => 5.0,
|
|
SeekDirection::Backward => -5.0,
|
|
};
|
|
let _ = app_handle.emit("media:seek-relative", delta);
|
|
}
|
|
MediaControlEvent::SetPosition(pos) => {
|
|
let secs = pos.0.as_secs_f64();
|
|
let _ = app_handle.emit("media:seek-absolute", secs);
|
|
}
|
|
_ => {}
|
|
}
|
|
}) {
|
|
crate::app_eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
|
|
}
|
|
Some(controls)
|
|
}
|
|
Err(e) => {
|
|
crate::app_eprintln!("[Psysonic] Could not create media controls: {e:?}");
|
|
None
|
|
}
|
|
}
|
|
})();
|
|
|
|
app.manage(MprisControls::new(maybe_controls));
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
app.manage(MprisControls::new(None));
|
|
}
|
|
|
|
// ── Windows Taskbar Thumbnail Toolbar ────────────────────────
|
|
#[cfg(all(target_os = "windows", not(debug_assertions)))]
|
|
{
|
|
use tauri::Manager;
|
|
if let Some(w) = app.get_webview_window("main") {
|
|
if let Ok(hwnd) = w.hwnd() {
|
|
taskbar_win::init(app.handle(), hwnd.0 as isize);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Audio device-change watcher ───────────────────────────────
|
|
{
|
|
use tauri::Manager;
|
|
let engine = app.state::<audio::AudioEngine>();
|
|
audio::start_device_watcher(&engine, app.handle().clone());
|
|
audio::start_stream_idle_watcher(app.handle().clone());
|
|
}
|
|
|
|
// ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.)
|
|
audio::register_post_sleep_audio_recovery(app.handle().clone());
|
|
|
|
// ── Pre-create mini player window (Windows) ──────────────────
|
|
// Creating the second WebView2 webview lazily from an invoke
|
|
// handler on Windows reliably stalls the Tauri event loop —
|
|
// the mini shows a blank white window, neither main nor mini
|
|
// can be closed, and the user has to kill the process via
|
|
// Task Manager. Building it at startup (hidden) avoids the
|
|
// runtime-creation code path entirely; later `open_mini_player`
|
|
// calls are pure show/hide.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Err(e) = build_mini_player_window(app.handle(), false) {
|
|
crate::app_eprintln!("[psysonic] Failed to pre-create mini window: {e}");
|
|
}
|
|
}
|
|
|
|
// 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| {
|
|
// Persist mini player position whenever the user drags it.
|
|
if window.label() == "mini" {
|
|
if let tauri::WindowEvent::Moved(pos) = event {
|
|
persist_mini_pos_throttled(window.app_handle(), pos.x, pos.y);
|
|
}
|
|
}
|
|
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
if window.label() == "main" {
|
|
api.prevent_close();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
// On macOS the red close button quits the app entirely.
|
|
// Route through JS so playback position + Orbit state get
|
|
// flushed; exit_app on the way back stops the audio engine.
|
|
let _ = window.emit("app:force-quit", ());
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
// Pause rendering before JS decides whether to hide to tray or exit.
|
|
if let Some(w) = window.app_handle().get_webview_window("main") {
|
|
let _ = w.eval(PAUSE_RENDERING_JS);
|
|
}
|
|
// Let JS decide: minimize to tray or exit, based on user setting.
|
|
let _ = window.emit("window:close-requested", ());
|
|
}
|
|
} else if window.label() == "mini" {
|
|
// Native close on the mini: hide instead of destroying so
|
|
// state is preserved, and restore the main window.
|
|
api.prevent_close();
|
|
if let Some(w) = window.app_handle().get_webview_window("mini") {
|
|
let _ = w.eval(PAUSE_RENDERING_JS);
|
|
}
|
|
let _ = window.hide();
|
|
if let Some(main) = window.app_handle().get_webview_window("main") {
|
|
let _ = main.unminimize();
|
|
let _ = main.show();
|
|
let _ = main.set_focus();
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.on_page_load(|webview, payload| {
|
|
if webview.label() != "main" {
|
|
return;
|
|
}
|
|
|
|
match payload.event() {
|
|
tauri::webview::PageLoadEvent::Started => {
|
|
let window = webview.window().clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_millis(48));
|
|
let _ = window.show();
|
|
});
|
|
}
|
|
tauri::webview::PageLoadEvent::Finished => {
|
|
let _ = webview.window().show();
|
|
}
|
|
}
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
greet,
|
|
theme_import::import_theme_zip,
|
|
backup_export_library_db,
|
|
backup_import_library_db,
|
|
backup_export_full,
|
|
backup_import_full,
|
|
migration_inspect,
|
|
migration_run,
|
|
resolve_host_addresses,
|
|
psysonic_syncfs::sync::batch::calculate_sync_payload,
|
|
exit_app,
|
|
cli_publish_player_snapshot,
|
|
cli_publish_library_list,
|
|
cli_publish_server_list,
|
|
cli_publish_search_results,
|
|
set_window_decorations,
|
|
set_linux_webkit_smooth_scrolling,
|
|
linux_wayland_gpu_font_tuning_active,
|
|
linux_wayland_text_render_settings_available,
|
|
set_linux_wayland_text_render_profile,
|
|
set_logging_mode,
|
|
get_logging_mode,
|
|
tail_runtime_logs,
|
|
export_runtime_logs,
|
|
frontend_debug_log,
|
|
performance_cpu_snapshot,
|
|
set_subsonic_wire_user_agent,
|
|
no_compositing_mode,
|
|
theme_animation_risk,
|
|
linux_xdg_session_type,
|
|
is_tiling_wm_cmd,
|
|
open_mini_player,
|
|
preload_mini_player,
|
|
close_mini_player,
|
|
set_mini_player_always_on_top,
|
|
resize_mini_player,
|
|
show_main_window,
|
|
pause_rendering,
|
|
resume_rendering,
|
|
register_global_shortcut,
|
|
unregister_global_shortcut,
|
|
mpris_set_metadata,
|
|
mpris_set_playback,
|
|
audio::commands::audio_play,
|
|
audio::transport_commands::audio_pause,
|
|
audio::transport_commands::audio_resume,
|
|
audio::transport_commands::audio_stop,
|
|
audio::transport_commands::audio_seek,
|
|
audio::mix_commands::audio_set_volume,
|
|
audio::mix_commands::audio_update_replay_gain,
|
|
audio::mix_commands::audio_set_eq,
|
|
audio::mix_commands::audio_set_playback_rate,
|
|
audio::autoeq_commands::autoeq_entries,
|
|
audio::autoeq_commands::autoeq_fetch_profile,
|
|
audio::preload_commands::audio_preload,
|
|
audio::radio_commands::audio_play_radio,
|
|
audio::preview::audio_preview_play,
|
|
audio::preview::audio_preview_stop,
|
|
audio::preview::audio_preview_stop_silent,
|
|
audio::preview::audio_preview_set_volume,
|
|
audio::mix_commands::audio_set_crossfade,
|
|
audio::mix_commands::audio_set_gapless,
|
|
audio::mix_commands::audio_set_normalization,
|
|
audio::device_commands::audio_list_devices,
|
|
audio::device_commands::audio_canonicalize_selected_device,
|
|
audio::device_commands::audio_default_output_device_name,
|
|
audio::device_commands::audio_set_device,
|
|
audio::commands::audio_chain_preload,
|
|
psysonic_integration::discord::discord_update_presence,
|
|
psysonic_integration::discord::discord_clear_presence,
|
|
psysonic_integration::remote::audioscrobbler_request,
|
|
psysonic_integration::remote::listenbrainz_request,
|
|
psysonic_integration::remote::maloja_request,
|
|
psysonic_integration::navidrome::covers::upload_playlist_cover,
|
|
psysonic_integration::navidrome::covers::upload_radio_cover,
|
|
psysonic_integration::navidrome::covers::upload_artist_image,
|
|
psysonic_integration::navidrome::covers::delete_radio_cover,
|
|
psysonic_integration::navidrome::users::navidrome_login,
|
|
psysonic_integration::navidrome::users::nd_list_users,
|
|
psysonic_integration::navidrome::users::nd_create_user,
|
|
psysonic_integration::navidrome::users::nd_update_user,
|
|
psysonic_integration::navidrome::users::nd_delete_user,
|
|
psysonic_integration::navidrome::queries::nd_list_libraries,
|
|
psysonic_integration::navidrome::queries::nd_list_songs,
|
|
psysonic_integration::navidrome::queries::nd_list_artists_by_role,
|
|
psysonic_integration::navidrome::queries::nd_list_albums_by_artist_role,
|
|
psysonic_integration::navidrome::queries::nd_set_user_libraries,
|
|
psysonic_integration::navidrome::playlists::nd_list_playlists,
|
|
psysonic_integration::navidrome::playlists::nd_create_playlist,
|
|
psysonic_integration::navidrome::playlists::nd_update_playlist,
|
|
psysonic_integration::navidrome::playlists::nd_get_playlist,
|
|
psysonic_integration::navidrome::playlists::nd_delete_playlist,
|
|
psysonic_integration::navidrome::queries::nd_get_song_path,
|
|
psysonic_integration::remote::search_radio_browser,
|
|
psysonic_integration::remote::get_top_radio_stations,
|
|
psysonic_integration::remote::fetch_url_bytes,
|
|
psysonic_integration::remote::fetch_json_url,
|
|
psysonic_integration::remote::fetch_icy_metadata,
|
|
psysonic_integration::remote::resolve_stream_url,
|
|
psysonic_analysis::commands::analysis_get_waveform,
|
|
psysonic_analysis::commands::analysis_get_waveform_for_track,
|
|
psysonic_analysis::commands::analysis_get_loudness_for_track,
|
|
psysonic_analysis::commands::analysis_delete_loudness_for_track,
|
|
psysonic_analysis::commands::analysis_delete_waveform_for_track,
|
|
psysonic_analysis::commands::analysis_delete_all_waveforms,
|
|
psysonic_analysis::commands::analysis_delete_all_for_server,
|
|
psysonic_analysis::commands::analysis_get_failed_track_count,
|
|
psysonic_analysis::commands::analysis_list_failed_tracks,
|
|
psysonic_analysis::commands::analysis_clear_failed_tracks,
|
|
psysonic_analysis::commands::analysis_migrate_server_index_keys,
|
|
psysonic_analysis::commands::analysis_enqueue_seed_from_url,
|
|
psysonic_analysis::commands::analysis_set_playback_priority_hints,
|
|
psysonic_analysis::commands::analysis_set_pipeline_parallelism,
|
|
psysonic_analysis::commands::analysis_get_pipeline_queue_stats,
|
|
psysonic_analysis::commands::analysis_get_backfill_queue_stats,
|
|
psysonic_analysis::commands::analysis_prune_pending_to_track_ids,
|
|
psysonic_library::commands::library_get_status,
|
|
psysonic_library::commands::library_search,
|
|
psysonic_library::commands::library_live_search,
|
|
psysonic_library::commands::library_advanced_search,
|
|
psysonic_library::commands::library_list_lossless_albums,
|
|
psysonic_library::commands::library_list_albums_by_genre,
|
|
psysonic_library::commands::library_genre_tags_inspect,
|
|
psysonic_library::commands::library_genre_tags_run,
|
|
psysonic_library::commands::library_get_artist_lossless_browse,
|
|
psysonic_library::commands::library_search_cross_server,
|
|
psysonic_library::commands::library_get_track,
|
|
psysonic_library::commands::library_get_tracks_batch,
|
|
psysonic_library::commands::library_get_tracks_by_album,
|
|
psysonic_library::commands::library_upsert_songs_from_api,
|
|
psysonic_library::commands::library_get_artifact,
|
|
psysonic_library::commands::library_get_facts,
|
|
psysonic_library::commands::library_get_offline_path,
|
|
psysonic_library::commands::library_analysis_progress,
|
|
psysonic_library::commands::library_count_live_tracks,
|
|
psysonic_library::commands::library_sync_bind_session,
|
|
psysonic_library::commands::library_sync_clear_session,
|
|
psysonic_library::commands::library_set_playback_hint,
|
|
psysonic_library::commands::library_get_playback_hint,
|
|
psysonic_library::commands::library_sync_start,
|
|
psysonic_library::commands::library_sync_verify_integrity,
|
|
psysonic_library::commands::library_sync_cancel,
|
|
psysonic_library::commands::library_patch_track,
|
|
psysonic_library::browse_support::library_reconcile_album_stars,
|
|
psysonic_library::browse_support::library_get_catalog_year_bounds,
|
|
psysonic_library::browse_support::library_get_genre_album_counts,
|
|
psysonic_library::commands::library_put_artifact,
|
|
psysonic_library::commands::library_put_fact,
|
|
psysonic_library::commands::library_record_play_session,
|
|
psysonic_library::commands::library_get_player_stats_year_summary,
|
|
psysonic_library::commands::library_get_player_stats_heatmap,
|
|
psysonic_library::commands::library_get_player_stats_day_detail,
|
|
psysonic_library::commands::library_get_player_stats_year_bounds,
|
|
psysonic_library::commands::library_get_player_stats_recent_days,
|
|
psysonic_library::commands::library_purge_server,
|
|
psysonic_library::commands::library_migrate_server_index_keys,
|
|
psysonic_library::commands::library_delete_server_data,
|
|
psysonic_library::commands::library_analysis_backfill_batch,
|
|
library_analysis_backfill::library_analysis_backfill_configure,
|
|
psysonic_library::commands::library_resolve_cover_entry,
|
|
cover_cache::cover_cache_peek_batch,
|
|
cover_cache::cover_cache_ensure,
|
|
cover_cache::cover_cache_ensure_batch,
|
|
cover_cache::cover_cache_stats,
|
|
cover_cache::cover_cache_evict_tick,
|
|
cover_cache::cover_cache_configure,
|
|
cover_cache::cover_cache_clear,
|
|
cover_cache::cover_cache_clear_server,
|
|
cover_cache::cover_cache_rename_server_bucket,
|
|
cover_cache::cover_cache_stats_server,
|
|
cover_cache::cover_cache_get_pipeline_queue_stats,
|
|
cover_cache::library_cover_backfill_batch,
|
|
cover_cache::library_cover_progress,
|
|
cover_cache::library_cover_catalog_size,
|
|
cover_cache::library_cover_clear_fetch_failures,
|
|
cover_cache::library_cover_backfill_configure,
|
|
cover_cache::library_cover_backfill_set_base_url,
|
|
cover_cache::library_cover_backfill_pulse,
|
|
cover_cache::library_cover_backfill_reset_cursor,
|
|
cover_cache::library_cover_backfill_set_ui_priority,
|
|
cover_cache::library_cover_backfill_set_parallel,
|
|
cover_cache::library_cover_backfill_run_full_pass,
|
|
cover_cache::cover_revalidate_enqueue,
|
|
cover_cache::cover_revalidate_tick,
|
|
cover_cache::cover_revalidate_batch,
|
|
psysonic_syncfs::cache::offline::download_track_offline,
|
|
psysonic_syncfs::cache::offline::cancel_offline_downloads,
|
|
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
|
psysonic_syncfs::cache::offline::delete_offline_track,
|
|
psysonic_syncfs::cache::offline::get_offline_cache_size,
|
|
psysonic_syncfs::cache::local::download_track_local,
|
|
psysonic_syncfs::cache::local::probe_library_track_local,
|
|
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
|
|
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
|
|
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
|
|
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
|
|
psysonic_syncfs::cache::local::probe_media_files,
|
|
psysonic_syncfs::cache::local::get_media_tier_size,
|
|
psysonic_syncfs::cache::local::purge_media_tier,
|
|
psysonic_syncfs::cache::local::delete_media_file,
|
|
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
|
|
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
|
|
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
|
|
psysonic_syncfs::cache::hot::download_track_hot_cache,
|
|
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
|
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
|
psysonic_syncfs::cache::hot::delete_hot_cache_track,
|
|
psysonic_syncfs::cache::hot::purge_hot_cache,
|
|
psysonic_syncfs::sync::device::sync_track_to_device,
|
|
psysonic_syncfs::sync::batch::sync_batch_to_device,
|
|
psysonic_syncfs::sync::batch::cancel_device_sync,
|
|
psysonic_syncfs::sync::device::compute_sync_paths,
|
|
psysonic_syncfs::sync::batch::list_device_dir_files,
|
|
psysonic_syncfs::sync::batch::delete_device_file,
|
|
psysonic_syncfs::sync::batch::delete_device_files,
|
|
psysonic_syncfs::sync::device::get_removable_drives,
|
|
psysonic_syncfs::sync::device::write_device_manifest,
|
|
psysonic_syncfs::sync::device::read_device_manifest,
|
|
psysonic_syncfs::sync::device::write_playlist_m3u8,
|
|
psysonic_syncfs::sync::device::rename_device_files,
|
|
toggle_tray_icon,
|
|
set_tray_tooltip,
|
|
set_tray_menu_labels,
|
|
check_dir_accessible,
|
|
psysonic_syncfs::cache::downloads::download_zip,
|
|
psysonic_syncfs::cache::downloads::check_arch_linux,
|
|
psysonic_syncfs::cache::downloads::download_update,
|
|
psysonic_syncfs::cache::downloads::open_folder,
|
|
psysonic_syncfs::cache::downloads::get_embedded_lyrics,
|
|
psysonic_syncfs::cache::downloads::fetch_netease_lyrics,
|
|
psysonic_integration::bandsintown::fetch_bandsintown_events,
|
|
#[cfg(target_os = "windows")]
|
|
taskbar_win::update_taskbar_icon,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running Psysonic");
|
|
}
|