diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index a0dffb5..4258275 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -135,6 +135,30 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) { } } +/// Refresh only the local-library half of an already open search. +/// +/// Library/device sync notifications can arrive while federated search +/// results are visible. Reusing `schedule_search` here would clear the +/// federation rows and bump the shared sequence, causing valid network +/// responses to be dropped or flicker away. +pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) { + let query = state.search.query.clone(); + if query.is_empty() { + return; + } + let seq = runtime.search_seq.load(Ordering::SeqCst); + let library = Arc::clone(&runtime.library); + state.search.loading = true; + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || library.search(&query, SEARCH_LIMIT)) + .await + .map_err(|err| err.to_string()) + .and_then(|result| result.map_err(|err| format!("{err:#}"))); + let _ = tx.send(AppEvent::SearchLoaded { seq, result }); + }); +} + /// Enter: close the line. Live commands already took effect (their view /// stays open); one-shot commands execute here. fn commit(state: &mut AppState, runtime: &mut Runtime) { diff --git a/src/app/mod.rs b/src/app/mod.rs index 808a071..c779058 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -561,13 +561,10 @@ fn record_active_handoff( active_device_name: state.device_playback.self_device_name.clone(), state: playback_state_from_ui(state), }; - if let Err(err) = runtime.devices.record_playback_command(&target, command) { - tracing::warn!(%err, target, "recording active handoff command failed"); - state.status_message = Some(format!("device handoff failed: {err:#}")); - } + record_playback_command_async(runtime, target, command, "device handoff"); } -fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) { +fn record_control_playback_state(state: &mut AppState, runtime: &Runtime, seek: bool) { if !state.device_playback.is_control() { return; } @@ -577,31 +574,77 @@ fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) { }; let command = crate::devices::PlaybackCommand::SetState { state: playback_state_from_ui(state), + seek, }; - if let Err(err) = runtime.devices.record_playback_command(&target, command) { - tracing::warn!(%err, target, "recording playback command failed"); - state.status_message = Some(format!("device command failed: {err:#}")); - } else { - request_urgent_device_sync(runtime); - } + record_playback_command_async(runtime, target, command, "device command"); +} + +fn record_playback_command_async( + runtime: &Runtime, + target: String, + command: crate::devices::PlaybackCommand, + label: &'static str, +) { + let devices = Arc::clone(&runtime.devices); + let sync_devices = Arc::clone(&runtime.devices); + let federation = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + let sync_tx = runtime.event_tx.clone(); + let running = Arc::clone(&runtime.device_sync_running); + let requested = Arc::clone(&runtime.device_sync_requested); + tokio::spawn(async move { + let target_for_write = target.clone(); + let result = tokio::task::spawn_blocking(move || { + devices.record_playback_command(&target_for_write, command) + }) + .await; + match result { + Ok(Ok(())) => { + request_urgent_device_sync_parts( + federation, + sync_devices, + sync_tx, + running, + requested, + ); + } + Ok(Err(err)) => { + tracing::warn!(%err, target, "recording playback command failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("{label} failed: {err:#}"))); + } + Err(err) => { + tracing::warn!(%err, target, "recording playback command task failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("{label} failed: {err}"))); + } + } + }); } fn request_urgent_device_sync(runtime: &Runtime) { + request_urgent_device_sync_parts( + Arc::clone(&runtime.federation), + Arc::clone(&runtime.devices), + runtime.event_tx.clone(), + Arc::clone(&runtime.device_sync_running), + Arc::clone(&runtime.device_sync_requested), + ); +} + +fn request_urgent_device_sync_parts( + federation: Arc, + devices: Arc, + tx: mpsc::UnboundedSender, + running: Arc, + requested: Arc, +) { use std::sync::atomic::Ordering; - runtime.device_sync_requested.store(true, Ordering::SeqCst); - if runtime - .device_sync_running + requested.store(true, Ordering::SeqCst); + if running .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { - spawn_urgent_device_sync( - Arc::clone(&runtime.federation), - Arc::clone(&runtime.devices), - runtime.event_tx.clone(), - Arc::clone(&runtime.device_sync_running), - Arc::clone(&runtime.device_sync_requested), - ); + spawn_urgent_device_sync(federation, devices, tx, running, requested); } } @@ -1171,12 +1214,14 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool { } fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { + let mut seek = false; match effect { Effect::PlayCurrent => { state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.playing = state.player.current.is_some(); state.player.paused = false; state.player.position_secs = 0.0; + seek = true; } Effect::TogglePause => {} Effect::StopPlayback => { @@ -1187,6 +1232,7 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, } Effect::SeekBy(delta) => { state.player.position_secs = (state.player.position_secs + delta as f64).max(0.0); + seek = true; } Effect::SetVolume(volume) => { state.player.volume = volume.min(100); @@ -1195,7 +1241,7 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, Effect::SetOptions | Effect::RemoveQueueIndices { .. } | Effect::PlaybackQueueChanged => {} _ => {} } - record_control_playback_state(state, runtime); + record_control_playback_state(state, runtime, seek); } fn clamp_settings_cursor(state: &mut AppState) { @@ -1856,7 +1902,8 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) { }); } - // A live search view shows stale rows now; run the query again. + // A live search view shows stale local rows now; refresh only the local + // half so device-sync/library events do not wipe federated results. if state .global .stack @@ -1864,7 +1911,7 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) { .any(|view| matches!(view, state::GlobalView::Search { .. })) && !state.search.query.is_empty() { - cmdline::schedule_search(state, runtime); + cmdline::refresh_local_search(state, runtime); } } @@ -2037,7 +2084,7 @@ fn handle_playback_command( command: crate::devices::PlaybackCommand, ) { match command { - crate::devices::PlaybackCommand::SetState { state: wire } => { + crate::devices::PlaybackCommand::SetState { state: wire, seek } => { let old_current_key = state.player.current.as_ref().map(track_playback_key); let old_playing = state.player.playing; let old_paused = state.player.paused; @@ -2063,9 +2110,11 @@ fn handle_playback_command( ); push_media_metadata(state, runtime); } else { - runtime.player.seek(std::time::Duration::from_secs_f64( - state.player.position_secs, - )); + if seek { + runtime.player.seek(std::time::Duration::from_secs_f64( + state.player.position_secs, + )); + } if state.player.paused && !old_paused { runtime.player.pause(); } else if !state.player.paused && old_paused { @@ -2604,7 +2653,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent AppEvent::EnqueueTracks { tracks, next } => { let count = tracks.len(); update::enqueue_tracks(state, tracks, next); - record_control_playback_state(state, runtime); + record_control_playback_state(state, runtime, false); state.status_message = Some(if next { format!("{count} tracks queued next") } else { @@ -2689,7 +2738,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent state.player.paused = false; state.player.current = None; if state.device_playback.is_control() { - record_control_playback_state(state, runtime); + record_control_playback_state(state, runtime, false); } else { runtime.player.stop(); push_media_update(state, runtime, true); diff --git a/src/devices.rs b/src/devices.rs index b00b2c2..9ec9222 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -255,6 +255,8 @@ pub struct PlaybackSnapshot { pub enum PlaybackCommand { SetState { state: PlaybackStateWire, + #[serde(default)] + seek: bool, }, ActiveChanged { active_device_id: String, @@ -3527,6 +3529,7 @@ mod tests { shuffle: false, repeat: PlaybackRepeat::Off, }, + seek: false, }; sync.apply_playback_command("dev_other", &command, "op_other")