Connected Devices: added remote control

This commit is contained in:
Ultradesu
2026-07-24 04:33:50 +03:00
parent 1065ea5afc
commit 389eacd388
4 changed files with 161 additions and 22 deletions
+72 -6
View File
@@ -31,6 +31,7 @@ use update::{Effect, update};
const TICK_INTERVAL: Duration = Duration::from_millis(250); const TICK_INTERVAL: Duration = Duration::from_millis(250);
const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50); 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. /// Handles shared by background tasks; AppState stays pure UI data.
pub struct Runtime { pub struct Runtime {
@@ -228,6 +229,9 @@ fn playback_state_from_ui(state: &AppState) -> crate::devices::PlaybackStateWire
queue_pos: state.player.queue_pos, queue_pos: state.player.queue_pos,
playing: state.player.playing, playing: state.player.playing,
paused: state.player.paused, 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, position_secs: state.player.position_secs,
volume: state.player.volume, volume: state.player.volume,
shuffle: state.player.shuffle, shuffle: state.player.shuffle,
@@ -262,6 +266,11 @@ fn apply_playback_state_to_ui(
.min(state.player.queue.len().saturating_sub(1)); .min(state.player.queue.len().saturating_sub(1));
state.player.playing = wire.playing && !state.player.queue.is_empty(); state.player.playing = wire.playing && !state.player.queue.is_empty();
state.player.paused = wire.paused; 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.position_secs = wire.position_secs.max(0.0);
state.player.volume = wire.volume.min(100); state.player.volume = wire.volume.min(100);
state.player.shuffle = wire.shuffle; 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 { let Ok((device_id, device_name)) = runtime.devices.identity_summary() else {
return; return;
}; };
update_local_idle_since(state);
state.device_playback.self_device_id = device_id.clone(); state.device_playback.self_device_id = device_id.clone();
state.device_playback.self_device_name = device_name.clone(); state.device_playback.self_device_name = device_name.clone();
if state.device_playback.role == state::DevicePlaybackRole::Active { 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); 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<i64> {
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) { fn extrapolate_control_position(state: &mut AppState) {
let Some(snapshot) = state.device_playback.last_remote_snapshot.as_ref() else { let Some(snapshot) = state.device_playback.last_remote_snapshot.as_ref() else {
return; 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_id = Some(device_id);
state.device_playback.active_device_name = Some(device_name); state.device_playback.active_device_name = Some(device_name);
state.device_playback.last_remote_snapshot = None; state.device_playback.last_remote_snapshot = None;
state.device_playback.local_idle_since_ms = None;
if was_control && start_audio && state.player.playing { if was_control && start_audio && state.player.playing {
start_current_audio( start_current_audio(
state, state,
@@ -376,6 +413,7 @@ fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) {
if !state.device_playback.is_control() { if !state.device_playback.is_control() {
return; return;
} }
update_local_idle_since(state);
let Some(target) = state.device_playback.active_device_id.clone() else { let Some(target) = state.device_playback.active_device_id.clone() else {
return; return;
}; };
@@ -1715,7 +1753,10 @@ fn handle_device_playback_snapshot(
if !snapshot.active { if !snapshot.active {
return; 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_active = state.device_playback.role == state::DevicePlaybackRole::Active;
let was_paused = state.player.playing && state.player.paused; let was_paused = state.player.playing && state.player.paused;
become_control_device(state, runtime, snapshot.clone()); 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), text: format!("Playback is now controlled by {}.", snapshot.device_name),
}); });
} }
} else if state.device_playback.is_control() return;
&& 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());
} }
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( fn handle_playback_command(
@@ -1887,6 +1933,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&placeholder_id); .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 { match result {
Ok(playable) => { Ok(playable) => {
if playable.imported { if playable.imported {
@@ -2095,6 +2148,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}; };
state.art.insert(key, entry); 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) => { AppEvent::Player(player::PlayerEvent::Started) => {
runtime.player_start_pending = false; 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); state.status_message = Some(message);
} }
AppEvent::PrefetchFailed { pos } => { AppEvent::PrefetchFailed { pos } => {
if state.device_playback.is_control() {
return;
}
if state.player.prefetched_pos == Some(pos) { if state.player.prefetched_pos == Some(pos) {
state.player.prefetched_pos = None; 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}")); state.status_message = Some(format!("importing {done}/{total}: {current}"));
} }
AppEvent::QueueTracksRefreshed { tracks } => { AppEvent::QueueTracksRefreshed { tracks } => {
if state.device_playback.is_control() {
return;
}
apply_queue_refresh(state, runtime, tracks); apply_queue_refresh(state, runtime, tracks);
} }
AppEvent::Media(command) => { AppEvent::Media(command) => {
+1
View File
@@ -950,6 +950,7 @@ pub struct DevicePlaybackState {
pub active_device_id: Option<String>, pub active_device_id: Option<String>,
pub active_device_name: Option<String>, pub active_device_name: Option<String>,
pub online_devices: usize, pub online_devices: usize,
pub local_idle_since_ms: Option<i64>,
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>, pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>, pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
} }
+3
View File
@@ -196,6 +196,8 @@ pub struct PlaybackStateWire {
pub queue_pos: usize, pub queue_pos: usize,
pub playing: bool, pub playing: bool,
pub paused: bool, pub paused: bool,
#[serde(default)]
pub idle_since_ms: Option<i64>,
pub position_secs: f64, pub position_secs: f64,
#[serde(default)] #[serde(default)]
pub volume: u8, pub volume: u8,
@@ -3436,6 +3438,7 @@ mod tests {
queue_pos: 0, queue_pos: 0,
playing: false, playing: false,
paused: false, paused: false,
idle_since_ms: None,
position_secs: 0.0, position_secs: 0.0,
volume: 42, volume: 42,
shuffle: false, shuffle: false,
+85 -16
View File
@@ -2,6 +2,7 @@ use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::theme; use super::theme;
use crate::app::state::{ 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)); display_lines.push(DisplayLine::Row(index));
} }
let height = 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 area = centered(frame.area(), 76, height);
let block = Block::bordered() let block = Block::bordered()
.title(" Connected devices ") .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([ let [summary_area, list_area, hint_area] = Layout::vertical([
Constraint::Length(1), Constraint::Length(1),
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(1), Constraint::Length(2),
]) ])
.areas(inner); .areas(inner);
let active = state.device_playback.active_label(); let active = state.device_playback.active_label();
@@ -132,31 +133,64 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
continue; continue;
}; };
let row = &rows[*index]; let row = &rows[*index];
let role = if row.revoked { let role = if row.active {
"revoked" ""
} else if row.active {
"active"
} else if row.is_self } else if row.is_self
&& state.device_playback.role == crate::app::state::DevicePlaybackRole::Control && state.device_playback.role == crate::app::state::DevicePlaybackRole::Control
{ {
"control" ""
} else { } else {
"device" "·"
}; };
let play = if row.playing && row.paused { let play = if row.playing && row.paused {
"paused" "II"
} else if row.playing { } else if row.playing {
"playing" ""
} else { } else {
"stopped" ""
}; };
let online = if row.online { "online" } else { "offline" }; let online = if row.online { "" } else { "" };
let marker = if row.is_self { "*" } 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![ let line = Line::from(vec![
Span::styled(format!("{marker} "), theme::accent()), Span::styled(format!("{marker} "), theme::accent()),
Span::raw(row.name.clone()), Span::raw(name),
Span::styled(format!(" {role} · {online} · {play}"), theme::dim()), Span::raw(gap),
Span::styled(format!(" · {} queued", row.queue_len), theme::dim()), 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); frame.render_widget(Paragraph::new(line), area);
if *index == selected { 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( frame.render_widget(
Paragraph::new(Line::styled( Paragraph::new(Line::styled(
"enter: control selected / move active here · esc close", "enter: control selected / move active here · esc close",
theme::dim(), theme::dim(),
)) ))
.alignment(Alignment::Center), .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) { fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(frame.area(), 46, 6); let area = centered(frame.area(), 46, 6);
let block = Block::bordered() let block = Block::bordered()