Connected Devices: added remote control

This commit is contained in:
Ultradesu
2026-07-24 05:42:16 +03:00
parent 3d3fd20b57
commit 888038931a
3 changed files with 106 additions and 30 deletions
+24
View File
@@ -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) {
+79 -30
View File
@@ -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<crate::federation::Federation>,
devices: Arc<crate::devices::DeviceSync>,
tx: mpsc::UnboundedSender<AppEvent>,
running: Arc<std::sync::atomic::AtomicBool>,
requested: Arc<std::sync::atomic::AtomicBool>,
) {
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);
+3
View File
@@ -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")