From 3d3fd20b577acbab9a4889284b6bdf9b4ab4e40a Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 24 Jul 2026 05:31:29 +0300 Subject: [PATCH] Connected Devices: added remote control --- src/app/mod.rs | 119 ++++++++++++++++++++++++++++++++++++++++++++----- src/devices.rs | 9 +++- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 95757fb..808a071 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -523,6 +523,8 @@ pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut request_urgent_device_sync(runtime); return; } + extrapolate_control_position(state); + let previous_active_id = state.device_playback.active_device_id.clone(); let should_start = state.player.current.is_some() || !state.player.queue.is_empty(); if state.player.current.is_none() && !state.player.queue.is_empty() { state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); @@ -539,9 +541,32 @@ pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut push_media_update(state, runtime, true); } publish_playback_snapshot(state, runtime); + record_active_handoff(state, runtime, previous_active_id); request_urgent_device_sync(runtime); } +fn record_active_handoff( + state: &mut AppState, + runtime: &Runtime, + previous_active_id: Option, +) { + let Some(target) = previous_active_id else { + return; + }; + if target == state.device_playback.self_device_id { + return; + } + let command = crate::devices::PlaybackCommand::ActiveChanged { + active_device_id: state.device_playback.self_device_id.clone(), + 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:#}")); + } +} + fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) { if !state.device_playback.is_control() { return; @@ -2050,6 +2075,30 @@ fn handle_playback_command( push_media_update(state, runtime, true); publish_playback_snapshot(state, runtime); } + crate::devices::PlaybackCommand::ActiveChanged { + active_device_id, + active_device_name, + state: wire, + } => { + if active_device_id == state.device_playback.self_device_id { + return; + } + let was_active = state.device_playback.role == state::DevicePlaybackRole::Active; + let snapshot = crate::devices::PlaybackSnapshot { + device_id: active_device_id, + device_name: active_device_name, + active: true, + updated_at_ms: unix_time_ms(), + state: wire, + }; + become_control_device(state, runtime, snapshot); + runtime.player_start_pending = false; + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + if was_active { + state.status_message = Some("active playback moved to another device".into()); + } + } } } @@ -2167,6 +2216,42 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent ); return; } + let placeholder_key = state + .player + .queue + .iter() + .find(|track| track.id == placeholder_id) + .or_else(|| { + state + .player + .current + .as_ref() + .filter(|track| track.id == placeholder_id) + }) + .map(track_playback_key); + let queue_pos_waiting = + state + .player + .queue + .get(state.player.queue_pos) + .is_some_and(|track| { + track.id == placeholder_id + || placeholder_key + .as_deref() + .is_some_and(|key| track_playback_key(track) == key) + }); + let current_waiting = state.player.current.as_ref().is_some_and(|current| { + current.id == placeholder_id + || placeholder_key + .as_deref() + .is_some_and(|key| track_playback_key(current) == key) + }); + let waiting = queue_pos_waiting || current_waiting; + let resume_position_secs = if waiting { + state.player.position_secs + } else { + 0.0 + }; match result { Ok(playable) => { if playable.imported { @@ -2180,33 +2265,43 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }); } let resolved = playable.track.clone(); + let resolved_key = track_playback_key(&resolved); // Swap the placeholder for the real track everywhere it // sits in the queue. for slot in &mut state.player.queue { - if slot.id == placeholder_id { + if slot.id == placeholder_id + || placeholder_key + .as_deref() + .is_some_and(|key| track_playback_key(slot) == key) + { *slot = resolved.clone(); } } - let waiting = state - .player - .current - .as_ref() - .is_some_and(|current| current.id == placeholder_id); if waiting { // Playback was parked on this track; start it now. let paused = state.player.paused; - start_current_audio(state, runtime, 0.0, paused); + if state + .player + .queue + .get(state.player.queue_pos) + .is_none_or(|track| track_playback_key(track) != resolved_key) + && let Some(pos) = state + .player + .queue + .iter() + .position(|track| track_playback_key(track) == resolved_key) + { + state.player.queue_pos = pos; + } + state.player.current = + state.player.queue.get(state.player.queue_pos).cloned(); + start_current_audio(state, runtime, resume_position_secs, paused); push_media_metadata(state, runtime); push_media_update(state, runtime, true); } } Err(message) => { state.status_message = Some(format!("federation: {message}")); - let waiting = state - .player - .current - .as_ref() - .is_some_and(|current| current.id == placeholder_id); if waiting { // Skip the failed track instead of stalling the queue. if state.player.queue_pos + 1 < state.player.queue.len() { diff --git a/src/devices.rs b/src/devices.rs index a005b61..b00b2c2 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -253,7 +253,14 @@ pub struct PlaybackSnapshot { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PlaybackCommand { - SetState { state: PlaybackStateWire }, + SetState { + state: PlaybackStateWire, + }, + ActiveChanged { + active_device_id: String, + active_device_name: String, + state: PlaybackStateWire, + }, } #[derive(Debug, Clone, Default)]