Connected Devices: added remote control
This commit is contained in:
@@ -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
|
/// Enter: close the line. Live commands already took effect (their view
|
||||||
/// stays open); one-shot commands execute here.
|
/// stays open); one-shot commands execute here.
|
||||||
fn commit(state: &mut AppState, runtime: &mut Runtime) {
|
fn commit(state: &mut AppState, runtime: &mut Runtime) {
|
||||||
|
|||||||
+79
-30
@@ -561,13 +561,10 @@ fn record_active_handoff(
|
|||||||
active_device_name: state.device_playback.self_device_name.clone(),
|
active_device_name: state.device_playback.self_device_name.clone(),
|
||||||
state: playback_state_from_ui(state),
|
state: playback_state_from_ui(state),
|
||||||
};
|
};
|
||||||
if let Err(err) = runtime.devices.record_playback_command(&target, command) {
|
record_playback_command_async(runtime, target, command, "device handoff");
|
||||||
tracing::warn!(%err, target, "recording active handoff command failed");
|
|
||||||
state.status_message = Some(format!("device handoff failed: {err:#}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
if !state.device_playback.is_control() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -577,31 +574,77 @@ fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) {
|
|||||||
};
|
};
|
||||||
let command = crate::devices::PlaybackCommand::SetState {
|
let command = crate::devices::PlaybackCommand::SetState {
|
||||||
state: playback_state_from_ui(state),
|
state: playback_state_from_ui(state),
|
||||||
|
seek,
|
||||||
};
|
};
|
||||||
if let Err(err) = runtime.devices.record_playback_command(&target, command) {
|
record_playback_command_async(runtime, target, command, "device command");
|
||||||
tracing::warn!(%err, target, "recording playback command failed");
|
}
|
||||||
state.status_message = Some(format!("device command failed: {err:#}"));
|
|
||||||
} else {
|
fn record_playback_command_async(
|
||||||
request_urgent_device_sync(runtime);
|
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) {
|
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;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
runtime.device_sync_requested.store(true, Ordering::SeqCst);
|
requested.store(true, Ordering::SeqCst);
|
||||||
if runtime
|
if running
|
||||||
.device_sync_running
|
|
||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
spawn_urgent_device_sync(
|
spawn_urgent_device_sync(federation, devices, tx, running, requested);
|
||||||
Arc::clone(&runtime.federation),
|
|
||||||
Arc::clone(&runtime.devices),
|
|
||||||
runtime.event_tx.clone(),
|
|
||||||
Arc::clone(&runtime.device_sync_running),
|
|
||||||
Arc::clone(&runtime.device_sync_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) {
|
fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||||
|
let mut seek = false;
|
||||||
match effect {
|
match effect {
|
||||||
Effect::PlayCurrent => {
|
Effect::PlayCurrent => {
|
||||||
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||||
state.player.playing = state.player.current.is_some();
|
state.player.playing = state.player.current.is_some();
|
||||||
state.player.paused = false;
|
state.player.paused = false;
|
||||||
state.player.position_secs = 0.0;
|
state.player.position_secs = 0.0;
|
||||||
|
seek = true;
|
||||||
}
|
}
|
||||||
Effect::TogglePause => {}
|
Effect::TogglePause => {}
|
||||||
Effect::StopPlayback => {
|
Effect::StopPlayback => {
|
||||||
@@ -1187,6 +1232,7 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
|
|||||||
}
|
}
|
||||||
Effect::SeekBy(delta) => {
|
Effect::SeekBy(delta) => {
|
||||||
state.player.position_secs = (state.player.position_secs + delta as f64).max(0.0);
|
state.player.position_secs = (state.player.position_secs + delta as f64).max(0.0);
|
||||||
|
seek = true;
|
||||||
}
|
}
|
||||||
Effect::SetVolume(volume) => {
|
Effect::SetVolume(volume) => {
|
||||||
state.player.volume = volume.min(100);
|
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 => {}
|
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) {
|
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
|
if state
|
||||||
.global
|
.global
|
||||||
.stack
|
.stack
|
||||||
@@ -1864,7 +1911,7 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
.any(|view| matches!(view, state::GlobalView::Search { .. }))
|
.any(|view| matches!(view, state::GlobalView::Search { .. }))
|
||||||
&& !state.search.query.is_empty()
|
&& !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,
|
command: crate::devices::PlaybackCommand,
|
||||||
) {
|
) {
|
||||||
match command {
|
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_current_key = state.player.current.as_ref().map(track_playback_key);
|
||||||
let old_playing = state.player.playing;
|
let old_playing = state.player.playing;
|
||||||
let old_paused = state.player.paused;
|
let old_paused = state.player.paused;
|
||||||
@@ -2063,9 +2110,11 @@ fn handle_playback_command(
|
|||||||
);
|
);
|
||||||
push_media_metadata(state, runtime);
|
push_media_metadata(state, runtime);
|
||||||
} else {
|
} else {
|
||||||
runtime.player.seek(std::time::Duration::from_secs_f64(
|
if seek {
|
||||||
state.player.position_secs,
|
runtime.player.seek(std::time::Duration::from_secs_f64(
|
||||||
));
|
state.player.position_secs,
|
||||||
|
));
|
||||||
|
}
|
||||||
if state.player.paused && !old_paused {
|
if state.player.paused && !old_paused {
|
||||||
runtime.player.pause();
|
runtime.player.pause();
|
||||||
} else if !state.player.paused && old_paused {
|
} 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 } => {
|
AppEvent::EnqueueTracks { tracks, next } => {
|
||||||
let count = tracks.len();
|
let count = tracks.len();
|
||||||
update::enqueue_tracks(state, tracks, next);
|
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 {
|
state.status_message = Some(if next {
|
||||||
format!("{count} tracks queued next")
|
format!("{count} tracks queued next")
|
||||||
} else {
|
} else {
|
||||||
@@ -2689,7 +2738,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
state.player.paused = false;
|
state.player.paused = false;
|
||||||
state.player.current = None;
|
state.player.current = None;
|
||||||
if state.device_playback.is_control() {
|
if state.device_playback.is_control() {
|
||||||
record_control_playback_state(state, runtime);
|
record_control_playback_state(state, runtime, false);
|
||||||
} else {
|
} else {
|
||||||
runtime.player.stop();
|
runtime.player.stop();
|
||||||
push_media_update(state, runtime, true);
|
push_media_update(state, runtime, true);
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ pub struct PlaybackSnapshot {
|
|||||||
pub enum PlaybackCommand {
|
pub enum PlaybackCommand {
|
||||||
SetState {
|
SetState {
|
||||||
state: PlaybackStateWire,
|
state: PlaybackStateWire,
|
||||||
|
#[serde(default)]
|
||||||
|
seek: bool,
|
||||||
},
|
},
|
||||||
ActiveChanged {
|
ActiveChanged {
|
||||||
active_device_id: String,
|
active_device_id: String,
|
||||||
@@ -3527,6 +3529,7 @@ mod tests {
|
|||||||
shuffle: false,
|
shuffle: false,
|
||||||
repeat: PlaybackRepeat::Off,
|
repeat: PlaybackRepeat::Off,
|
||||||
},
|
},
|
||||||
|
seek: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
sync.apply_playback_command("dev_other", &command, "op_other")
|
sync.apply_playback_command("dev_other", &command, "op_other")
|
||||||
|
|||||||
Reference in New Issue
Block a user