diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 40992890..2046d898 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -3064,7 +3064,12 @@ pub async fn audio_chain_preload( let raw_bytes = Arc::new(data); - let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); + // Only `gain_linear` is needed — `effective_volume` is intentionally NOT + // applied to the Sink here. `audio_chain_preload` runs ~30 s before the + // current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl. + // the still-playing current source). Volume for the chained track is + // applied at the gapless transition in `spawn_progress_task`, not here. + let (gain_linear, _effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); let done_next = Arc::new(AtomicBool::new(false)); // Use a dedicated counter for the chained source — it will be swapped into @@ -3116,11 +3121,11 @@ pub async fn audio_chain_preload( } // Append to the existing Sink. The audio hardware stream never stalls. + // Note: `set_volume` is deliberately NOT called here (see comment above). { let cur = state.current.lock().unwrap(); match &cur.sink { Some(sink) => { - sink.set_volume(effective_volume); sink.append(source); } None => return Ok(()), // playback stopped — bail @@ -3208,7 +3213,12 @@ fn spawn_progress_task( // a one-time value copy would go stale immediately. samples_played = info.sample_counter; - // Update tracking state. + // Update tracking state and apply the chained track's + // effective volume. Deferred from `audio_chain_preload` + // (which runs ~30 s before the current track ends) to + // avoid changing loudness of the still-playing current + // track. `Sink::set_volume` affects the whole Sink, so it + // must only be called at the boundary, not at preload. { let mut cur = current_arc.lock().unwrap(); cur.replay_gain_linear = info.replay_gain_linear; @@ -3216,6 +3226,10 @@ fn spawn_progress_task( cur.duration_secs = info.duration_secs; cur.seek_offset = 0.0; cur.play_started = Some(Instant::now()); + if let Some(sink) = &cur.sink { + let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); + sink.set_volume(effective); + } } // Record the gapless switch timestamp for ghost-command guard. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e42a14cd..baec6304 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -157,6 +157,12 @@ fn export_runtime_logs(path: String) -> Result { crate::logging::export_logs_to_file(&path) } +#[tauri::command] +fn frontend_debug_log(scope: String, message: String) -> Result<(), String> { + crate::app_deprintln!("[frontend][{}] {}", scope, message); + Ok(()) +} + #[tauri::command] fn set_subsonic_wire_user_agent( user_agent: String, @@ -2747,8 +2753,10 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { "show_hide" => { if let Some(win) = app.get_webview_window("main") { if win.is_visible().unwrap_or(false) { + let _ = win.eval(PAUSE_RENDERING_JS); let _ = win.hide(); } else { + let _ = win.eval(RESUME_RENDERING_JS); let _ = win.show(); let _ = win.set_focus(); } @@ -2781,8 +2789,10 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { let app = tray.app_handle(); if let Some(win) = app.get_webview_window("main") { if win.is_visible().unwrap_or(false) { + let _ = win.eval(PAUSE_RENDERING_JS); let _ = win.hide(); } else { + let _ = win.eval(RESUME_RENDERING_JS); let _ = win.show(); let _ = win.set_focus(); } @@ -3002,6 +3012,27 @@ fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) { write_mini_pos(app, MiniPlayerPosition { x, y }); } +/// Returns true when the saved top-left lands inside an available monitor +/// with enough room (≥ 80 px in each axis) to leave a draggable corner of +/// the window on-screen. Used to drop persisted positions that point at a +/// monitor that is no longer enumerated (unplugged, hot-plug detection +/// race during early boot, resolution change, monitor reorder). +fn mini_position_visible(app: &tauri::AppHandle, x: i32, y: i32) -> bool { + const MIN_VISIBLE: i32 = 80; + let monitors = match app.available_monitors() { + Ok(m) if !m.is_empty() => m, + _ => return false, + }; + monitors.iter().any(|m| { + let mp = m.position(); + let ms = m.size(); + x >= mp.x + && y >= mp.y + && x + MIN_VISIBLE <= mp.x + ms.width as i32 + && y + MIN_VISIBLE <= mp.y + ms.height as i32 + }) +} + /// Default position when nothing is persisted: bottom-right of the monitor /// the main window sits on (falls back to primary). A 24 px logical margin /// keeps it off the screen edge; +56 px on the bottom margin avoids most @@ -3027,6 +3058,45 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option