From 389eacd388fa7093190c19f8fc3ccca844acc5e5 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 24 Jul 2026 04:33:50 +0300 Subject: [PATCH] Connected Devices: added remote control --- src/app/mod.rs | 78 +++++++++++++++++++++++++++++++++--- src/app/state.rs | 1 + src/devices.rs | 3 ++ src/ui/popup.rs | 101 +++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 161 insertions(+), 22 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index e305544..4a51be4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -31,6 +31,7 @@ use update::{Effect, update}; const TICK_INTERVAL: Duration = Duration::from_millis(250); const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50); +const ACTIVE_IDLE_LEASE_MS: i64 = 5 * 60 * 1000; /// Handles shared by background tasks; AppState stays pure UI data. pub struct Runtime { @@ -228,6 +229,9 @@ fn playback_state_from_ui(state: &AppState) -> crate::devices::PlaybackStateWire queue_pos: state.player.queue_pos, playing: state.player.playing, paused: state.player.paused, + idle_since_ms: (!state.player.playing || state.player.paused) + .then_some(state.device_playback.local_idle_since_ms) + .flatten(), position_secs: state.player.position_secs, volume: state.player.volume, shuffle: state.player.shuffle, @@ -262,6 +266,11 @@ fn apply_playback_state_to_ui( .min(state.player.queue.len().saturating_sub(1)); state.player.playing = wire.playing && !state.player.queue.is_empty(); state.player.paused = wire.paused; + state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused { + None + } else { + wire.idle_since_ms.or_else(|| Some(unix_time_ms())) + }; state.player.position_secs = wire.position_secs.max(0.0); state.player.volume = wire.volume.min(100); state.player.shuffle = wire.shuffle; @@ -287,6 +296,7 @@ fn publish_playback_snapshot(state: &mut AppState, runtime: &Runtime) { let Ok((device_id, device_name)) = runtime.devices.identity_summary() else { return; }; + update_local_idle_since(state); state.device_playback.self_device_id = device_id.clone(); state.device_playback.self_device_name = device_name.clone(); if state.device_playback.role == state::DevicePlaybackRole::Active { @@ -303,6 +313,32 @@ fn publish_playback_snapshot(state: &mut AppState, runtime: &Runtime) { runtime.devices.publish_playback(snapshot); } +fn update_local_idle_since(state: &mut AppState) { + if !state.player.playing || state.player.paused { + if state.device_playback.local_idle_since_ms.is_none() { + state.device_playback.local_idle_since_ms = Some(unix_time_ms()); + } + } else { + state.device_playback.local_idle_since_ms = None; + } +} + +fn active_snapshot_idle_since(snapshot: &crate::devices::PlaybackSnapshot) -> Option { + if snapshot.state.playing && !snapshot.state.paused { + None + } else { + snapshot + .state + .idle_since_ms + .or(Some(snapshot.updated_at_ms)) + } +} + +fn active_idle_lease_expired(snapshot: &crate::devices::PlaybackSnapshot, now: i64) -> bool { + active_snapshot_idle_since(snapshot) + .is_some_and(|idle_since| now.saturating_sub(idle_since) >= ACTIVE_IDLE_LEASE_MS) +} + fn extrapolate_control_position(state: &mut AppState) { let Some(snapshot) = state.device_playback.last_remote_snapshot.as_ref() else { return; @@ -361,6 +397,7 @@ pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime, state.device_playback.active_device_id = Some(device_id); state.device_playback.active_device_name = Some(device_name); state.device_playback.last_remote_snapshot = None; + state.device_playback.local_idle_since_ms = None; if was_control && start_audio && state.player.playing { start_current_audio( state, @@ -376,6 +413,7 @@ fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) { if !state.device_playback.is_control() { return; } + update_local_idle_since(state); let Some(target) = state.device_playback.active_device_id.clone() else { return; }; @@ -1715,7 +1753,10 @@ fn handle_device_playback_snapshot( if !snapshot.active { return; } - if snapshot.state.playing { + let lease_expired = active_idle_lease_expired(&snapshot, now); + let already_controls_this_device = state.device_playback.is_control() + && state.device_playback.active_device_id.as_deref() == Some(snapshot.device_id.as_str()); + if !lease_expired || already_controls_this_device { let was_active = state.device_playback.role == state::DevicePlaybackRole::Active; let was_paused = state.player.playing && state.player.paused; become_control_device(state, runtime, snapshot.clone()); @@ -1725,12 +1766,17 @@ fn handle_device_playback_snapshot( text: format!("Playback is now controlled by {}.", snapshot.device_name), }); } - } else if state.device_playback.is_control() - && state.device_playback.active_device_id.as_deref() == Some(snapshot.device_id.as_str()) - { - become_active_device(state, runtime, false); - state.status_message = Some("active playback moved to this device".into()); + return; } + + if state.device_playback.is_control() { + return; + } + become_active_device(state, runtime, false); + state.status_message = Some(format!( + "active playback moved here; {} was idle for 5m", + snapshot.device_name + )); } fn handle_playback_command( @@ -1887,6 +1933,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&placeholder_id); + if state.device_playback.is_control() { + tracing::debug!( + placeholder_id, + "ignored local federated track resolution while controlling remote playback" + ); + return; + } match result { Ok(playable) => { if playable.imported { @@ -2095,6 +2148,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }; state.art.insert(key, entry); } + AppEvent::Player(event) if state.device_playback.is_control() => { + runtime.player_start_pending = false; + tracing::debug!( + ?event, + "ignored local player event while controlling remote playback" + ); + } AppEvent::Player(player::PlayerEvent::Started) => { runtime.player_start_pending = false; } @@ -2138,6 +2198,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent state.status_message = Some(message); } AppEvent::PrefetchFailed { pos } => { + if state.device_playback.is_control() { + return; + } if state.player.prefetched_pos == Some(pos) { state.player.prefetched_pos = None; } @@ -2278,6 +2341,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent state.status_message = Some(format!("importing {done}/{total}: {current}")); } AppEvent::QueueTracksRefreshed { tracks } => { + if state.device_playback.is_control() { + return; + } apply_queue_refresh(state, runtime, tracks); } AppEvent::Media(command) => { diff --git a/src/app/state.rs b/src/app/state.rs index b565135..f93d26a 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -950,6 +950,7 @@ pub struct DevicePlaybackState { pub active_device_id: Option, pub active_device_name: Option, pub online_devices: usize, + pub local_idle_since_ms: Option, pub remote: BTreeMap, pub last_remote_snapshot: Option, } diff --git a/src/devices.rs b/src/devices.rs index 0607423..79e1a32 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -196,6 +196,8 @@ pub struct PlaybackStateWire { pub queue_pos: usize, pub playing: bool, pub paused: bool, + #[serde(default)] + pub idle_since_ms: Option, pub position_secs: f64, #[serde(default)] pub volume: u8, @@ -3436,6 +3438,7 @@ mod tests { queue_pos: 0, playing: false, paused: false, + idle_since_ms: None, position_secs: 0.0, volume: 42, shuffle: false, diff --git a/src/ui/popup.rs b/src/ui/popup.rs index ad71d9b..d2851a1 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -2,6 +2,7 @@ use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::theme; use crate::app::state::{ @@ -77,7 +78,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { display_lines.push(DisplayLine::Row(index)); } let height = - (display_lines.len() as u16 + 5).clamp(7, frame.area().height.saturating_sub(2).max(7)); + (display_lines.len() as u16 + 6).clamp(8, frame.area().height.saturating_sub(2).max(8)); let area = centered(frame.area(), 76, height); let block = Block::bordered() .title(" Connected devices ") @@ -90,7 +91,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { let [summary_area, list_area, hint_area] = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), - Constraint::Length(1), + Constraint::Length(2), ]) .areas(inner); let active = state.device_playback.active_label(); @@ -132,31 +133,64 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { continue; }; let row = &rows[*index]; - let role = if row.revoked { - "revoked" - } else if row.active { - "active" + let role = if row.active { + "◆" } else if row.is_self && state.device_playback.role == crate::app::state::DevicePlaybackRole::Control { - "control" + "◇" } else { - "device" + "·" }; let play = if row.playing && row.paused { - "paused" + "II" } else if row.playing { - "playing" + "▶" } else { - "stopped" + "■" }; - let online = if row.online { "online" } else { "offline" }; + let online = if row.online { "●" } else { "○" }; let marker = if row.is_self { "*" } else { " " }; + let status = format!("{online} {role} {play} Q{}", row.queue_len); + let marker_width = UnicodeWidthStr::width(format!("{marker} ").as_str()); + let status_width = UnicodeWidthStr::width(status.as_str()); + let total_width = usize::from(area.width); + let name_width = total_width.saturating_sub(marker_width + status_width + 1); + let name = clip_cells(&row.name, name_width); + let used_width = marker_width + UnicodeWidthStr::width(name.as_str()) + status_width; + let gap = " ".repeat(total_width.saturating_sub(used_width)); let line = Line::from(vec![ Span::styled(format!("{marker} "), theme::accent()), - Span::raw(row.name.clone()), - Span::styled(format!(" {role} · {online} · {play}"), theme::dim()), - Span::styled(format!(" · {} queued", row.queue_len), theme::dim()), + Span::raw(name), + Span::raw(gap), + Span::styled( + online, + if row.online { + theme::accent() + } else { + theme::dim() + }, + ), + Span::raw(" "), + Span::styled( + role, + if row.active { + theme::accent() + } else { + theme::dim() + }, + ), + Span::raw(" "), + Span::styled( + play, + if row.playing { + theme::accent() + } else { + theme::dim() + }, + ), + Span::raw(" "), + Span::styled(format!("Q{}", row.queue_len), theme::dim()), ]); frame.render_widget(Paragraph::new(line), area); if *index == selected { @@ -164,16 +198,51 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { } } + let [legend_area, controls_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(hint_area); + frame.render_widget( + Paragraph::new(Line::styled( + "● on ○ off ◆ active ◇ control ▶ play II pause ■ stop Q queue", + theme::dim(), + )) + .alignment(Alignment::Center), + legend_area, + ); frame.render_widget( Paragraph::new(Line::styled( "enter: control selected / move active here · esc close", theme::dim(), )) .alignment(Alignment::Center), - hint_area, + controls_area, ); } +fn clip_cells(text: &str, max_width: usize) -> String { + if UnicodeWidthStr::width(text) <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "…".to_string(); + } + + let mut out = String::new(); + let mut width = 1usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > max_width { + break; + } + out.push(ch); + width += ch_width; + } + out.push('…'); + out +} + fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { let area = centered(frame.area(), 46, 6); let block = Block::bordered()