Connected Devices: added remote control
This commit is contained in:
+72
-6
@@ -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<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) {
|
||||
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) => {
|
||||
|
||||
@@ -950,6 +950,7 @@ pub struct DevicePlaybackState {
|
||||
pub active_device_id: Option<String>,
|
||||
pub active_device_name: Option<String>,
|
||||
pub online_devices: usize,
|
||||
pub local_idle_since_ms: Option<i64>,
|
||||
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
|
||||
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@ pub struct PlaybackStateWire {
|
||||
pub queue_pos: usize,
|
||||
pub playing: bool,
|
||||
pub paused: bool,
|
||||
#[serde(default)]
|
||||
pub idle_since_ms: Option<i64>,
|
||||
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,
|
||||
|
||||
+85
-16
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user