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 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) => {
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user