Connected Devices: added remote control
This commit is contained in:
@@ -409,6 +409,31 @@ pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime,
|
|||||||
publish_playback_snapshot(state, runtime);
|
publish_playback_snapshot(state, runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut Runtime) {
|
||||||
|
if state.device_playback.role == state::DevicePlaybackRole::Active {
|
||||||
|
publish_playback_snapshot(state, runtime);
|
||||||
|
request_urgent_device_sync(runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
state.player.position_secs = 0.0;
|
||||||
|
}
|
||||||
|
if state.player.current.is_some() {
|
||||||
|
state.player.playing = true;
|
||||||
|
state.player.paused = false;
|
||||||
|
}
|
||||||
|
become_active_device(state, runtime, false);
|
||||||
|
if should_start && state.player.current.is_some() {
|
||||||
|
start_current_audio(state, runtime, state.player.position_secs, false);
|
||||||
|
push_media_metadata(state, runtime);
|
||||||
|
push_media_update(state, runtime, true);
|
||||||
|
}
|
||||||
|
publish_playback_snapshot(state, runtime);
|
||||||
|
request_urgent_device_sync(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) {
|
fn record_control_playback_state(state: &mut AppState, runtime: &Runtime) {
|
||||||
if !state.device_playback.is_control() {
|
if !state.device_playback.is_control() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+8
-13
@@ -23,7 +23,6 @@ pub(crate) struct ConnectedDevicePopupRow {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_self: bool,
|
pub is_self: bool,
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
pub revoked: bool,
|
|
||||||
pub section: DevicePresenceSection,
|
pub section: DevicePresenceSection,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
@@ -49,7 +48,6 @@ pub(crate) fn connected_device_rows(state: &AppState) -> Vec<ConnectedDevicePopu
|
|||||||
name: state::device_display_name(device),
|
name: state::device_display_name(device),
|
||||||
is_self,
|
is_self,
|
||||||
online,
|
online,
|
||||||
revoked: device.revoked,
|
|
||||||
section,
|
section,
|
||||||
active: state::device_status_active(state, &device.device_id),
|
active: state::device_status_active(state, &device.device_id),
|
||||||
playing: if is_self {
|
playing: if is_self {
|
||||||
@@ -83,7 +81,6 @@ pub(crate) fn connected_device_rows(state: &AppState) -> Vec<ConnectedDevicePopu
|
|||||||
name: state.device_playback.self_device_name.clone(),
|
name: state.device_playback.self_device_name.clone(),
|
||||||
is_self: true,
|
is_self: true,
|
||||||
online: true,
|
online: true,
|
||||||
revoked: false,
|
|
||||||
section: DevicePresenceSection::Online,
|
section: DevicePresenceSection::Online,
|
||||||
active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active,
|
active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active,
|
||||||
playing: state.player.playing,
|
playing: state.player.playing,
|
||||||
@@ -173,7 +170,9 @@ fn handle_connected_devices(
|
|||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
) {
|
) {
|
||||||
let rows = connected_device_rows(state);
|
let rows = connected_device_rows(state);
|
||||||
let last = rows.len().saturating_sub(1);
|
let other_rows: Vec<_> = rows.iter().filter(|row| !row.is_self).cloned().collect();
|
||||||
|
let last = other_rows.len();
|
||||||
|
let cursor = cursor.min(last);
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
@@ -187,15 +186,11 @@ fn handle_connected_devices(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some(row) = rows.get(cursor.min(last)) {
|
if cursor == 0 {
|
||||||
if row.revoked {
|
super::transfer_active_to_this_device(state, runtime);
|
||||||
state.status_message = Some("revoked device cannot be controlled".into());
|
state.status_message = Some("active playback moved to this device".into());
|
||||||
} else if row.is_self {
|
} else if let Some(row) = other_rows.get(cursor.saturating_sub(1).min(last)) {
|
||||||
super::become_active_device(state, runtime, true);
|
if let Some(snapshot) = state.device_playback.remote.get(&row.device_id).cloned() {
|
||||||
state.status_message = Some("active playback moved to this device".into());
|
|
||||||
} else if let Some(snapshot) =
|
|
||||||
state.device_playback.remote.get(&row.device_id).cloned()
|
|
||||||
{
|
|
||||||
super::become_control_device(state, runtime, snapshot);
|
super::become_control_device(state, runtime, snapshot);
|
||||||
} else {
|
} else {
|
||||||
state.status_message = Some("device has no playback snapshot yet".into());
|
state.status_message = Some("device has no playback snapshot yet".into());
|
||||||
|
|||||||
+5
-2
@@ -1204,7 +1204,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
|||||||
} else {
|
} else {
|
||||||
format!("queued: {count} tracks")
|
format!("queued: {count} tracks")
|
||||||
});
|
});
|
||||||
return None;
|
return Some(Effect::PlaybackQueueChanged);
|
||||||
}
|
}
|
||||||
if let Some(id) = selected_release_id(state) {
|
if let Some(id) = selected_release_id(state) {
|
||||||
return Some(Effect::EnqueueRelease { id, next });
|
return Some(Effect::EnqueueRelease { id, next });
|
||||||
@@ -3119,7 +3119,10 @@ mod tests {
|
|||||||
|
|
||||||
update(&mut state, Action::ToggleTrackSelection);
|
update(&mut state, Action::ToggleTrackSelection);
|
||||||
update(&mut state, Action::MoveDown);
|
update(&mut state, Action::MoveDown);
|
||||||
assert_eq!(update(&mut state, Action::QueueAddLast), None,);
|
assert_eq!(
|
||||||
|
update(&mut state, Action::QueueAddLast),
|
||||||
|
Some(Effect::PlaybackQueueChanged),
|
||||||
|
);
|
||||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||||
assert_eq!(queued, vec![1, 2]);
|
assert_eq!(queued, vec![1, 2]);
|
||||||
assert!(!state.track_selection.is_active());
|
assert!(!state.track_selection.is_active());
|
||||||
|
|||||||
+165
-72
@@ -64,13 +64,16 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
|
|
||||||
fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||||
let rows = crate::app::popup::connected_device_rows(state);
|
let rows = crate::app::popup::connected_device_rows(state);
|
||||||
|
let self_row = rows.iter().find(|row| row.is_self);
|
||||||
|
let other_rows: Vec<_> = rows.iter().filter(|row| !row.is_self).collect();
|
||||||
|
let cursor = cursor.min(other_rows.len());
|
||||||
enum DisplayLine {
|
enum DisplayLine {
|
||||||
Section(DevicePresenceSection),
|
Section(DevicePresenceSection),
|
||||||
Row(usize),
|
Row(usize),
|
||||||
}
|
}
|
||||||
let mut display_lines = Vec::new();
|
let mut display_lines = Vec::new();
|
||||||
let mut last_section = None;
|
let mut last_section = None;
|
||||||
for (index, row) in rows.iter().enumerate() {
|
for (index, row) in other_rows.iter().enumerate() {
|
||||||
if last_section != Some(row.section) {
|
if last_section != Some(row.section) {
|
||||||
display_lines.push(DisplayLine::Section(row.section));
|
display_lines.push(DisplayLine::Section(row.section));
|
||||||
last_section = Some(row.section);
|
last_section = Some(row.section);
|
||||||
@@ -78,7 +81,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 + 6).clamp(8, frame.area().height.saturating_sub(2).max(8));
|
(display_lines.len() as u16 + 9).clamp(11, frame.area().height.saturating_sub(2).max(11));
|
||||||
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 ")
|
||||||
@@ -88,8 +91,9 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let [summary_area, list_area, hint_area] = Layout::vertical([
|
let [summary_area, this_area, other_area, hint_area] = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(3),
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
])
|
])
|
||||||
@@ -106,11 +110,56 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
summary_area,
|
summary_area,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
render_subtitle(frame, this_area, "This device");
|
||||||
|
let action_area = Rect {
|
||||||
|
x: this_area.x,
|
||||||
|
y: this_area.y + 1,
|
||||||
|
width: this_area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let action_label =
|
||||||
|
if state.device_playback.role == crate::app::state::DevicePlaybackRole::Active {
|
||||||
|
"This device is active"
|
||||||
|
} else {
|
||||||
|
"Make this device active"
|
||||||
|
};
|
||||||
|
let self_name = self_row
|
||||||
|
.map(|row| row.name.as_str())
|
||||||
|
.unwrap_or(state.device_playback.self_device_name.as_str());
|
||||||
|
let self_status = self_row
|
||||||
|
.map(device_status_label)
|
||||||
|
.unwrap_or_else(|| "● · ■ Q0".to_string());
|
||||||
|
render_connected_action(
|
||||||
|
frame,
|
||||||
|
action_area,
|
||||||
|
cursor == 0,
|
||||||
|
action_label,
|
||||||
|
self_name,
|
||||||
|
&self_status,
|
||||||
|
);
|
||||||
|
|
||||||
|
render_subtitle(frame, other_area, "Other devices");
|
||||||
|
let list_area = Rect {
|
||||||
|
x: other_area.x,
|
||||||
|
y: other_area.y + 1,
|
||||||
|
width: other_area.width,
|
||||||
|
height: other_area.height.saturating_sub(1),
|
||||||
|
};
|
||||||
|
if other_rows.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::styled(" no other devices yet", theme::dim())),
|
||||||
|
list_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let visible = usize::from(list_area.height.max(1));
|
let visible = usize::from(list_area.height.max(1));
|
||||||
let selected = cursor.min(rows.len().saturating_sub(1));
|
let selected_remote = cursor.checked_sub(1);
|
||||||
let selected_line = display_lines
|
let selected_line = selected_remote
|
||||||
.iter()
|
.and_then(|selected| {
|
||||||
.position(|line| matches!(line, DisplayLine::Row(index) if *index == selected))
|
display_lines
|
||||||
|
.iter()
|
||||||
|
.position(|line| matches!(line, DisplayLine::Row(index) if *index == selected))
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let first = selected_line
|
let first = selected_line
|
||||||
.saturating_sub(visible / 2)
|
.saturating_sub(visible / 2)
|
||||||
@@ -132,70 +181,8 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let row = &rows[*index];
|
let row = other_rows[*index];
|
||||||
let role = if row.active {
|
render_connected_device_row(frame, area, row, selected_remote == Some(*index));
|
||||||
"◆"
|
|
||||||
} else if row.is_self
|
|
||||||
&& state.device_playback.role == crate::app::state::DevicePlaybackRole::Control
|
|
||||||
{
|
|
||||||
"◇"
|
|
||||||
} else {
|
|
||||||
"·"
|
|
||||||
};
|
|
||||||
let play = if row.playing && row.paused {
|
|
||||||
"II"
|
|
||||||
} else if row.playing {
|
|
||||||
"▶"
|
|
||||||
} else {
|
|
||||||
"■"
|
|
||||||
};
|
|
||||||
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(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 {
|
|
||||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let [legend_area, controls_area] =
|
let [legend_area, controls_area] =
|
||||||
@@ -210,7 +197,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled(
|
Paragraph::new(Line::styled(
|
||||||
"enter: control selected / move active here · esc close",
|
"enter: activate this device / control selected · esc close",
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
))
|
))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
@@ -218,6 +205,112 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_subtitle(frame: &mut Frame, area: Rect, title: &'static str) {
|
||||||
|
let rect = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: area.y,
|
||||||
|
width: area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
frame.render_widget(Paragraph::new(Line::styled(title, theme::header())), rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_status_label(row: &crate::app::popup::ConnectedDevicePopupRow) -> String {
|
||||||
|
let online = if row.online { "●" } else { "○" };
|
||||||
|
let role = if row.active {
|
||||||
|
"◆"
|
||||||
|
} else if row.is_self {
|
||||||
|
"◇"
|
||||||
|
} else {
|
||||||
|
"·"
|
||||||
|
};
|
||||||
|
let play = if row.playing && row.paused {
|
||||||
|
"II"
|
||||||
|
} else if row.playing {
|
||||||
|
"▶"
|
||||||
|
} else {
|
||||||
|
"■"
|
||||||
|
};
|
||||||
|
format!("{online} {role} {play} Q{}", row.queue_len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_connected_action(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
selected: bool,
|
||||||
|
label: &str,
|
||||||
|
device_name: &str,
|
||||||
|
status: &str,
|
||||||
|
) {
|
||||||
|
let marker = if selected { "▶ " } else { " " };
|
||||||
|
let prefix = format!("{marker}{label}");
|
||||||
|
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
|
||||||
|
let status_width = UnicodeWidthStr::width(status);
|
||||||
|
let name_width = usize::from(area.width).saturating_sub(prefix_width + status_width + 3);
|
||||||
|
let suffix = format!("{} {status}", clip_cells(device_name, name_width));
|
||||||
|
let suffix_width = UnicodeWidthStr::width(suffix.as_str());
|
||||||
|
let total_width = usize::from(area.width);
|
||||||
|
let label = if prefix_width + suffix_width + 1 > total_width {
|
||||||
|
let available = total_width.saturating_sub(suffix_width + UnicodeWidthStr::width(marker));
|
||||||
|
format!("{marker}{}", clip_cells(label, available))
|
||||||
|
} else {
|
||||||
|
prefix
|
||||||
|
};
|
||||||
|
let gap = " ".repeat(total_width.saturating_sub(
|
||||||
|
UnicodeWidthStr::width(label.as_str()) + UnicodeWidthStr::width(suffix.as_str()),
|
||||||
|
));
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
label,
|
||||||
|
if selected {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::raw(gap),
|
||||||
|
Span::styled(suffix, theme::dim()),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(line), area);
|
||||||
|
if selected {
|
||||||
|
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_connected_device_row(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
row: &crate::app::popup::ConnectedDevicePopupRow,
|
||||||
|
selected: bool,
|
||||||
|
) {
|
||||||
|
let marker = if selected { "▶ " } else { " " };
|
||||||
|
let status = device_status_label(row);
|
||||||
|
let marker_width = UnicodeWidthStr::width(marker);
|
||||||
|
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(
|
||||||
|
marker,
|
||||||
|
if selected {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::raw(name),
|
||||||
|
Span::raw(gap),
|
||||||
|
Span::styled(status, theme::dim()),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(line), area);
|
||||||
|
if selected {
|
||||||
|
frame.buffer_mut().set_style(area, theme::tab_active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn clip_cells(text: &str, max_width: usize) -> String {
|
fn clip_cells(text: &str, max_width: usize) -> String {
|
||||||
if UnicodeWidthStr::width(text) <= max_width {
|
if UnicodeWidthStr::width(text) <= max_width {
|
||||||
return text.to_string();
|
return text.to_string();
|
||||||
|
|||||||
Reference in New Issue
Block a user